Pad/internal/editor/state.go
Greg Pomerantz 275a78efaa Fix selection-handle drags, menu anchoring, and left-edge back-gesture theft
Three user-reported selection bugs, one root cause each:

1. Start handle ungrabbable at line start. Two interacting causes:
   a) The 48dp grab box straddles two visual lines; a finger in the
      lower half mapped (by y-to-line) to the neighbouring line, whose
      byte past the other handle clamped to a zero-length selection ->
      cleared on the first drag event. The cleared selection
      un-registered the drag op, so the router silently stopped
      delivering drag events (the observed 'stream cutoff'). Fix:
      handle drags now project the finger's x onto the anchor's own
      visual line (visualLineOfByte + textPosOnLineAtX); the anchor
      never crosses lines during a handle drag.
   b) A horizontal flick from the line-start handle (screen x~26px)
      started the system back gesture, which cancelled the touch
      stream. Fix: report the handle grab rects as system gesture
      exclusion rects (setSystemGestureExclusionRects, API 29+),
      marshalled to the UI thread via a PadExcl smali Runnable
      (generated identically by build_emu.sh/build_phone.sh).

2. End-handle drag downward made the menu chase the finger and cover
   the selection. Fix: the menu anchors to the STABLE end of the
   selection (the end not being dragged), so it stays parked by the
   selection start, clear of the finger and the highlighted text.

3. Menu above the selection vanished permanently when the selection
   was extended onto the top line. Fix: off-window anchors no longer
   hide the menu while any part of the selection is visible (keep-last
   rect, clamped); hiding happens only for fully off-window selections.

Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
2026-08-18 13:09:29 -04:00

2359 lines
76 KiB
Go

package editor
import (
"fmt"
"log"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
"gioui.org/io/key"
"pad/internal/browser"
"pad/internal/ui"
)
// EditorFontSize is the font size used for editor text.
const EditorFontSize = 14 // unit.Sp
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
const EditorLineHeightScale = 1.2
// EditorLineHeight returns the fixed line height in Dp for the editor font.
func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
}
// formatSize renders a byte count as a human-readable size (e.g. "10.4 MB").
func formatSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMG"[exp])
}
// Page identifies which page the app is showing.
type Page int
const (
BrowserPage Page = iota
EditorPage
)
// SortMode controls how the browser list is sorted.
type SortMode int
const (
SortByDateDesc SortMode = iota // default: newest first
SortByDateAsc
SortByNameAsc
SortByNameDesc
)
// EditorState holds all editor-specific state.
type EditorState struct {
Buffer string // DEPRECATED: use ChunkedBuffer for large files
ChunkedBuffer *ChunkedBuffer // NEW: chunked file access for virtual scrolling
CursorPosition int
GlyphLayout ui.GlyphLayout
SelectionStart int
SelectionEnd int
// SelectionAnchor is the fixed end of a shift-selection; CursorPosition is
// the active end. -1 means no shift-selection in progress. The effective
// selection is [min(anchor,cursor), max(anchor,cursor)]; SelectionStart/
// SelectionEnd are its cached normalized form (-1/-1 = none).
SelectionAnchor int
CursorVisible bool
// IMEWindowStartByte is the absolute byte offset in the buffer where the
// visible window (IMEWindowText) begins. For small (string) files it is 0
// and the window is the whole buffer; for large (chunked) files it is the
// viewport start. The IME snippet is the window, so an EditEvent.Range is
// relative to the window and must be offset by this to address the buffer.
IMEWindowStartByte int
// IMEWindowText is the visible window text shown to the IME (the snippet).
// It is set during layout and is what an EditEvent.Range indexes into.
IMEWindowText string
// EditSeq counts content edits (incremented by markDirty). The shaped
// glyph layout arriving via layoutChan is only applied to the WrapIndex
// when its EditSeq matches, so a layout shaped before an edit can never
// stamp stale wrap counts onto shifted lines.
EditSeq uint64
// ShowIMESeq pulses whenever the logic layer wants the soft keyboard up
// (file open, tap/double-tap on the editor). The renderer issues
// SoftKeyboardCmd{Show:true} only on a change (see TextField.ShowIMESeq),
// so a user-dismissed keyboard stays down until the next pulse.
ShowIMESeq uint64
// --- Touch selection (v1) ---
// CaretDrag: after a long press on blank space a single draggable caret
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
// floating copy/cut/paste menu (positioned below the line of the selection
// end; items are recomputed when the menu is shown). SelDragging/
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
// handle). All of it is logic-owned; the renderer only reports finger
// positions and draws the geometry.
CaretDrag bool
MenuVisible bool
MenuRect ui.Region
MenuItems []ui.MenuItem
SelDragging bool
SelDragWhich int
SelDragRel int
Filename string
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
// editor shows a "too large to edit" notice instead of content (the
// browser can still list the file).
TooLarge bool
TooLargeSize int64
fileVersion map[string]int
lastWriteVersion map[string]int
saveTimer *time.Timer
writeFailed map[string]bool // Added: tracks failed writes for UI
retryAttempts map[string]int // Added: tracks retry attempts
}
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
func (e *EditorState) GetBuffer() string {
if e.ChunkedBuffer != nil {
fullContent, err := e.ChunkedBuffer.FullContent()
if err != nil {
log.Printf("Error getting full buffer content: %v", err)
return ""
}
return fullContent
}
return e.Buffer
}
// IsSaving returns true if a save is pending.
func (e *EditorState) IsSaving() bool {
return e.saveTimer != nil
}
// IsDirty, WriteFailed, and RetryAttempts are computed:
func (e *EditorState) IsDirty() bool {
bufVer := e.fileVersion[e.Filename]
writeVer := e.lastWriteVersion[e.Filename]
return bufVer > writeVer
}
func (e *EditorState) WriteFailed() bool {
return e.writeFailed[e.Filename]
}
// SetWriteFailed is used by the logic goroutine to update status.
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
e.writeFailed[filename] = failed
if !failed {
e.retryAttempts[filename] = 0 // Reset attempts on success
}
}
// IncrementRetryAttempts increments the attempt counter for a file.
func (e *EditorState) IncrementRetryAttempts(filename string) int {
e.retryAttempts[filename]++
return e.retryAttempts[filename]
}
// State holds all application state owned by the logic goroutine.
type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
page Page // current page (Browser or Editor)
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
ByteOffset int // Byte offset of the first visible line
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
// for the viewport. Set by EditorLayout each frame; read by the profiler
// probe to confirm the shaped range stays viewport-bounded (not the file).
VisibleStart int
VisibleEnd int
// WindowStartLine is the logical line the visible editor window starts
// at, for the last computed layout (-1 when no editor window is shown).
// It is shipped with the shaped glyph layout (Frame) so the layout
// correlation pass applies wrap counts to the lines that layout
// describes, not the current window (a scroll may have moved it).
WindowStartLine int
FocusedElementID string // ID of the currently focused element
Elems []ui.Element
lastEvictionTime time.Time // Throttles chunk eviction
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
// EditorRegion is the editor text region in app-local Dp, recomputed on
// every layout. Input handlers (tap, long press, selection drags) convert
// app-local points to text-local coordinates through it.
EditorRegion ui.Region
// Clipboard channels (touch-selection menu). ClipboardSetChan and
// PasteReqChan are consumed by the main goroutine, which executes the Gio
// clipboard ops and reports the read result back on PasteChan. Buffered so
// a harness without a main loop never blocks the logic goroutine.
clipboardSetChan chan string
pasteReqChan chan struct{}
pasteChan chan string
// Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer
// Editor state
Editor EditorState // New field
open func(string)
}
func NewState() *State {
return &State{
scale: 1.0,
page: BrowserPage, // Reverted to BrowserPage
WordWrap: true, // Enable word wrap by default
lastEvictionTime: time.Now(),
Browser: *browser.NewBrowserState(),
Editor: EditorState{
CursorPosition: 0,
SelectionStart: -1,
SelectionEnd: -1,
SelectionAnchor: -1,
fileVersion: make(map[string]int),
lastWriteVersion: make(map[string]int),
writeFailed: make(map[string]bool),
retryAttempts: make(map[string]int),
},
clipboardSetChan: make(chan string, 16),
pasteReqChan: make(chan struct{}, 16),
pasteChan: make(chan string, 16),
}
}
func (s *State) SetScale(scale float32) {
s.scale = scale
}
func (s *State) SetFontScale(fs float32) {
s.fontScale = fs
}
func (s *State) Scale() float32 {
return s.scale
}
// stateFontScale returns the user font-size setting (1.0 when unknown).
func stateFontScale() float32 {
if TheState != nil && TheState.fontScale > 0 {
return TheState.fontScale
}
return 1
}
// EffectiveLineHeight is the editor line height in density-dp WITH the user
// font-size setting applied. The shaper draws baselines at
// Sp(EditorFontSize*LineHeightScale) physical px, which is
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
// clamping, cursor vertical move) must use this value rather than the raw
// EditorLineHeight; at a non-default font setting the two differ by the
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
// of lines and make scroll clamping stop short of (or run past) the file
// ends.
func EffectiveLineHeight() ui.Dp {
return EffectiveLineHeightAt(stateFontScale())
}
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
// (used where the live State value is not the right source, e.g. tests).
func EffectiveLineHeightAt(fs float32) ui.Dp {
if fs <= 0 {
fs = 1
}
return ui.Dp(float64(EditorLineHeight()) * float64(fs))
}
// layout converts stored pixel dimensions to Dp using the current scale
// and computes the element tree. Called only when a frame is needed.
// Search query sync is handled by the logic goroutine via searchQueryChan,
// not here, to ensure proper channel-based state flow.
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
// Calculate VisibleCount before laying out the browser page.
// This ensures the browser shows entries based on the current viewport.
if s.PixelHeight > 0 && s.scale > 0 {
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
rowHeight := ui.Dp(48)
newVisibleCount := int(listAreaHeight / rowHeight)
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
s.Browser.VisibleCount = newVisibleCount
}
}
switch s.page {
case BrowserPage:
tapHandler := func(data any) {
if idx, ok := data.(int); ok {
browser.HandleBrowserTap(bm, &s.Browser, idx)
}
}
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
case EditorPage:
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
}
return s.Elems
}
// ToggleWordWrap toggles the word wrap setting.
func ToggleWordWrap(data any) {
TheState.WordWrap = !TheState.WordWrap
}
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
// Also evicts chunks far from the cursor to keep memory bounded.
func HandleScroll(data any) {
// Swallow scroll from the opening gesture: the tap that opened the file can
// leak a scroll delta into the editor before the line index is built (when
// MaxScroll is a large estimate), leaving the viewport past the content.
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
return
}
delta := data.(int) // pixels
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
if TheState.ScrollOffset > TheState.MaxScroll {
TheState.ScrollOffset = TheState.MaxScroll
}
// Evict chunks far from the cursor to keep memory bounded.
// Only evict when the cursor is near the viewport (i.e., scrolled to top).
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
// Temporarily disabled eviction to debug thrashing issues.
/*
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
TheState.lastEvictionTime = time.Now()
}
}
*/
}
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
func HandleBrowserScroll(data any) {
delta := data.(int) // pixel delta from gesture.Scroll.Update
browser.HandlePixelScroll(&TheState.Browser, delta)
}
// GoToBrowser switches the app to the browser page.
func GoToBrowser(data any) {
if TheLogic != nil {
TheLogic.FlushAll()
}
TheState.page = BrowserPage
}
// GoToEditor switches the app to the editor page.
func GoToEditor(data any) {
TheState.page = EditorPage
}
// OpenFile sets the active filename and switches to the editor page.
// data is the filename string from the browser list.
func OpenFile(data any) {
filename := data.(string)
// NOTE: the external open hook (TheState.open, e.g. the Android Termux
// bridge) is intentionally NOT called here. Opening a file is an in-app
// action; the external bridge was the old tap path and it crashes on
// Android 7+ (FileUriExposedException from a file:// Intent URI).
// Dispatch a request to load the file
go func() {
TheLogic.openFileChan <- filename
}()
TheState.Editor.Filename = filename
TheState.Editor.CursorPosition = 0 // Reset cursor to top
TheState.Editor.TooLarge = false
TheState.Editor.TooLargeSize = 0
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
TheState.page = EditorPage
TheState.FocusedElementID = "editor_text" // Set focus to editor
TheState.Editor.ShowIMESeq++ // raise the keyboard for the newly opened file
// The tap that opened this file is delivered to the browser row, but its
// gesture can leak into the now-visible editor and move the cursor/scroll.
// Record the open time so the editor can swallow taps in a short window
// right after open (the opening tap, not a deliberate editor tap).
TheState.justOpenedAt = time.Now()
}
// SetChunkedBuffer sets the chunked buffer for the current editor state.
func SetChunkedBuffer(cb *ChunkedBuffer) {
TheState.Editor.ChunkedBuffer = cb
}
// ToggleSortOrder cycles the browser sort mode through four modes.
func ToggleSortOrder(data any) {
// Cycle through the 4 sort modes
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
// Reset scroll on sort change
TheState.Browser.ScrollOffset = 0
// Clear cached pages so they reload with the new sort order
TheState.Browser.Pages = make(map[int]*browser.Page)
// Reload initial pages with the new sort order
browser.LoadInitialPages(&TheState.Browser)
// Recompute search results if there's an active search query
browser.HandleSortModeChange(&TheState.Browser)
}
// HandleCursorMove updates the cursor position within bounds.
func HandleCursorMove(delta int) {
newPos := TheState.Editor.CursorPosition + delta
if newPos < 0 {
newPos = 0
}
// Use ChunkedBuffer.FileLen() as the authoritative upper bound
var maxPos int
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
maxPos = int(cb.FileLen())
}
if maxPos == 0 {
// Fallback to Buffer length if ChunkedBuffer not yet initialized
maxPos = len(TheState.Editor.Buffer)
}
if newPos > maxPos {
newPos = maxPos
}
TheState.Editor.CursorPosition = newPos
}
// HandleKeyDown interprets keyboard events for navigation and editing.
// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with
// modifier state) or bare key.Name (legacy/test path, no modifiers).
func HandleKeyDown(data any) {
// A too-large file is not editable: ignore all key input.
if TheState.Editor.TooLarge {
return
}
switch v := data.(type) {
case key.EditEvent:
// Text input from IME / keyboard.
if v.Text == "\b" {
// Backspace character (legacy / hardware): delete the selection if
// there is one, else one char before the cursor.
HandleBackspace()
} else {
// IME insert/replace/delete: replace [Range.Start, Range.End) with
// Text. Range is empty (start==end) for plain inserts; a non-empty
// range with text is a swipe/autocorrect replacement; a non-empty
// range with empty text is a range delete. Ignoring Range here is
// what caused replaced text to be duplicated. A live selection is
// always replaced (see HandleReplaceRange).
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
}
case ui.KeyEvent:
handleKey(v.Name, v.Shift)
case key.Name:
// Bare key name: no modifier state, so no shift-selection here.
handleKey(v, false)
}
}
// handleKey dispatches a key press. shift=true turns cursor moves into
// selection extensions; edit keys act on the selection when one is active.
func handleKey(name key.Name, shift bool) {
switch name {
case key.NameLeftArrow:
moveCursor(shift, func() { HandleCursorMove(-1) })
case key.NameRightArrow:
moveCursor(shift, func() { HandleCursorMove(1) })
case key.NameUpArrow:
moveCursor(shift, func() { HandleVerticalCursorMove(true) })
case key.NameDownArrow:
moveCursor(shift, func() { HandleVerticalCursorMove(false) })
case key.NameDeleteBackward:
HandleBackspace()
case key.NameDeleteForward:
HandleDelete()
case key.NameReturn:
HandleInsert("\n")
case key.NameHome:
moveCursor(shift, HandleHome)
case key.NameEnd:
moveCursor(shift, HandleEnd)
case key.NamePageUp:
moveCursor(shift, func() { HandlePageUpDown(true) })
case key.NamePageDown:
moveCursor(shift, func() { HandlePageUpDown(false) })
}
}
// glyphBase returns the absolute file byte offset at the start of the current
// visible window. The GlyphLayout is shaped from the visible window alone, so
// every one of its ByteOffsets is relative to this base; adding it yields an
// absolute file offset. For a whole-file window (small files, or scroll 0) the
// base is 0 and this is a no-op.
func glyphBase() int {
return TheState.Editor.IMEWindowStartByte
}
// --- Text selection ---------------------------------------------------------
//
// The selection is a byte range in absolute file coordinates derived from
// SelectionAnchor (fixed end) and CursorPosition (active end). No selection:
// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/
// end/page extend the selection; a plain cursor move or any edit clears it.
// All of these must run on the logic goroutine (owner).
// selActive reports whether there is a non-empty selection.
func selActive() bool {
e := &TheState.Editor
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
}
// ClearSelection drops the selection and the shift-anchor. It also dismisses
// the selection menu and cancels any in-progress drag bookkeeping: every path
// that clears the selection (tap, plain cursor move, any edit) should take
// the menu and handles down with it.
func ClearSelection() {
e := &TheState.Editor
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.MenuVisible = false
e.MenuItems = nil
e.SelDragging = false
}
// SetSelection selects the byte range [min(start,end), max(start,end)),
// anchoring at the lower end and placing the cursor (active end) at the upper
// end. Clamped to the buffer; an empty/clamped range clears instead.
// Convenience for tests and future gestures (tap-range, double-tap).
func SetSelection(start, end int) {
e := &TheState.Editor
if start > end {
start, end = end, start
}
if start < 0 {
start = 0
}
var fileLen int
if cb := e.ChunkedBuffer; cb != nil {
fileLen = int(cb.FileLen())
} else {
fileLen = len(e.Buffer)
}
if end > fileLen {
end = fileLen
}
if end <= start {
ClearSelection()
e.CursorPosition = start
return
}
e.SelectionAnchor = start
e.SelectionStart = start
e.SelectionEnd = end
e.CursorPosition = end
}
// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor,
// cursor). A zero-length range (anchor == cursor) is treated as no selection,
// but the anchor is kept so the next shift-move extends from the original spot
// again.
func updateSelectionFromAnchor() {
e := &TheState.Editor
if e.SelectionAnchor < 0 {
ClearSelection()
return
}
a, c := e.SelectionAnchor, e.CursorPosition
if a == c {
e.SelectionStart = -1
e.SelectionEnd = -1
return
}
if a < c {
e.SelectionStart, e.SelectionEnd = a, c
} else {
e.SelectionStart, e.SelectionEnd = c, a
}
}
// moveCursor runs op (a cursor-mutating handler). With shift held it keeps
// the anchor and extends the selection to the new cursor position; without it
// it clears any selection first. This is the single place where selection
// bookkeeping meets cursor movement, so every move path (arrows, home/end,
// page, vertical) gets consistent semantics.
func moveCursor(shift bool, op func()) {
if shift {
if TheState.Editor.SelectionAnchor < 0 {
TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition
}
} else {
ClearSelection()
}
op()
if shift {
updateSelectionFromAnchor()
}
}
// deleteRange removes the byte range [start, end) from the active buffer and
// updates the line index. Shared by the selection-aware edit handlers.
func deleteRange(start, end int) {
if end <= start {
return
}
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
buf.Delete(start, end-start)
buf.UpdateLineIndexAfterDelete(start, end)
} else {
str := TheState.Editor.Buffer
TheState.Editor.Buffer = str[:start] + str[end:]
}
}
// --- 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.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
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 first event already positions the
// handle (no grab offset needed).
}
if !ok {
return // finger outside the laid-out window: keep last position
}
switch e.SelDragWhich {
case 0: // start handle
// Project the finger's x onto the ANCHOR's own visual line. The grab
// box is 48dp and straddles the neighbouring line, so a finger on the
// lower half of the box maps to the line below; mapping to the finger's
// line used to clamp the anchor onto the other handle and collapse
// (clear) the selection on the very first drag event.
if line, ok2 := visualLineOfByte(e.SelectionStart - glyphBase()); ok2 {
if p2, ok3 := textPosOnLineAtX(line, localX); ok3 {
pos = p2
}
}
if pos > e.SelectionEnd {
pos = e.SelectionEnd
}
SetSelection(pos, e.SelectionEnd)
case 1: // end handle
if line, ok2 := visualLineOfByte(e.SelectionEnd - glyphBase()); ok2 {
if p2, ok3 := textPosOnLineAtX(line, localX); ok3 {
pos = p2
}
}
if pos < e.SelectionStart {
pos = e.SelectionStart
}
SetSelection(e.SelectionStart, pos)
case 2: // body: move the whole selection, preserving length
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
e.CursorPosition = pos
}
}
// visualLineOfByte returns the visual line (0-based within the shaped
// window) that holds the insertion point at the given window-relative byte
// offset, using the same rule the renderer uses to place the handles
// (handleAt): the line of the first glyph whose byte offset is at or past
// the offset, or the last line when the offset is past the last glyph.
func visualLineOfByte(winByte int) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 {
return 0, false
}
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)
markDirty()
}
return
}
// Fallback to string-based editing for small files / no chunked buffer
str := TheState.Editor.Buffer
if pos >= len(str) {
return
}
end := len(str)
if pos+4 < end {
end = pos + 4
}
TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):]
markDirty()
}
// HandleInsert inserts a string at the current cursor position. With a live
// selection it replaces the selection instead.
func HandleInsert(s string) {
// EditorState is embedded by value in State: take the address, never a
// copy, or the writes below are lost.
e := &TheState.Editor
pos := e.CursorPosition
if selActive() {
pos = e.SelectionStart
deleteRange(e.SelectionStart, e.SelectionEnd)
ClearSelection()
}
buf := e.ChunkedBuffer
if buf != nil {
buf.Insert(pos, s)
buf.UpdateLineIndexAfterInsert(pos, s)
} else {
// Fallback to string-based editing for small files / no chunked buffer
str := e.Buffer
e.Buffer = str[:pos] + s + str[pos:]
}
e.CursorPosition = pos + len(s)
markDirty()
}
// HandleBackspace removes the character before the cursor. With a live
// selection it deletes the whole selection instead.
func HandleBackspace() {
e := &TheState.Editor
if selActive() {
deleteRange(e.SelectionStart, e.SelectionEnd)
e.CursorPosition = e.SelectionStart
ClearSelection()
markDirty()
return
}
pos := e.CursorPosition
if pos == 0 {
return
}
buf := e.ChunkedBuffer
if buf != nil {
segStart := pos - 4
if segStart < 0 {
segStart = 0
}
w := utf8BackspaceWidth(buf.Content(segStart, pos))
buf.Delete(pos-w, w)
buf.UpdateLineIndexAfterDelete(pos-w, pos)
TheState.Editor.CursorPosition = pos - w
markDirty()
return
}
// Fallback to string-based editing for small files / no chunked buffer
str := TheState.Editor.Buffer
segStart := pos - 4
if segStart < 0 {
segStart = 0
}
w := utf8BackspaceWidth(str[segStart:pos])
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
TheState.Editor.CursorPosition = pos - w
markDirty()
}
// runeIndexToByteStr returns the byte offset of the n-th rune (0-indexed) in
// s. A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte lead byte
// (>=0xC0); 0x80-0xBF are continuation bytes. If n is past the end, returns
// len(s).
func runeIndexToByteStr(s string, n int) int {
if n <= 0 {
return 0
}
runes := 0
for i := 0; i < len(s); i++ {
b := s[i]
if b < 0x80 || b >= 0xC0 {
if runes == n {
return i
}
runes++
}
}
return len(s)
}
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
// with text and places the cursor at the end of the inserted text.
//
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
// or autocorrect commit replaces the selected region instead of blindly
// inserting at the cursor, so the old text is removed (no duplication). It
// also covers plain inserts (start==end) and range deletes (text == "").
//
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
// is byte-based, so they are converted to byte offsets first. Must be called
// on the logic goroutine (owner).
func HandleReplaceRange(startRune, endRune int, text string) {
// A too-large file is not editable: ignore IME commits.
if TheState.Editor.TooLarge {
return
}
if startRune > endRune {
startRune, endRune = endRune, startRune
}
// The IME snippet is the visible window (IMEWindowText), so startRune/
// endRune are relative to that window. Resolve them to window-byte
// offsets, then add the window's absolute start to address the buffer.
// For small (string) files the window is the whole buffer (start 0), so
// this reduces to absolute addressing. If IMEWindowText is empty (tests
// that never run layout), fall back to the whole buffer as the window.
windowStart := TheState.Editor.IMEWindowStartByte
windowText := TheState.Editor.IMEWindowText
if windowText == "" {
windowStart = 0
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
windowText, _ = cb.FullContent()
} else {
windowText = TheState.Editor.Buffer
}
}
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
// A live selection is always replaced by the commit: union the IME range
// with the selection so the outcome is deterministic no matter what the
// IME reports (some IMEs send the full selection range, others send an
// empty range at the caret expecting the app to consume its reported
// selection).
if selActive() {
if absStart > TheState.Editor.SelectionStart {
absStart = TheState.Editor.SelectionStart
}
if absEnd < TheState.Editor.SelectionEnd {
absEnd = TheState.Editor.SelectionEnd
}
}
if imeDebugLog {
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
}
var newCursor int
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
if absEnd > absStart {
buf.Delete(absStart, absEnd-absStart)
}
buf.Insert(absStart, text)
if absEnd > absStart {
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
}
buf.UpdateLineIndexAfterInsert(absStart, text)
newCursor = absStart + len(text)
} else {
s := TheState.Editor.Buffer
if absEnd > absStart {
s = s[:absStart] + s[absEnd:]
}
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
newCursor = absStart + len(text)
}
TheState.Editor.CursorPosition = newCursor
// A commit consumed any selection it overlapped (see the union above).
ClearSelection()
if imeDebugLog {
dbgBuf := currentEditorText()
if len(dbgBuf) > 40 {
dbgBuf = dbgBuf[:40]
}
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
}
markDirty()
}
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
// use; enable when debugging IME commit/cursor sync on device.
const imeDebugLog = false
// currentEditorText returns the current editor buffer contents (full for
// chunked files). Used only for debug logging.
func currentEditorText() string {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
s, _ := cb.FullContent()
return s
}
return TheState.Editor.Buffer
}
func markDirty() {
// 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.
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. ---
statusBarRegion := ui.Region{
X: margin, Y: margin,
W: screenWidth - margin*2,
H: ui.Dp(32),
}
statusBarW := statusBarRegion.W
filename := TheState.Editor.Filename
if filename == "" {
filename = "untitled.txt"
}
statusBar := ui.NewContainer(
statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32), Y: ui.Dp(6), W: statusBarW - ui.Dp(32), H: ui.Dp(20)}, ui.AlignStart, "", nil),
},
)
// --- Bottom bar ---
bottomBarHeight := ui.BottomBarHeight
bottomBarY := screenHeight - margin - bottomBarHeight
bottomBarRegion := ui.Region{
X: margin, Y: bottomBarY,
W: screenWidth - margin*2,
H: bottomBarHeight,
}
bottomBarW := bottomBarRegion.W
wrapText := "Wrap: Off"
if wordWrap {
wrapText = "Wrap: On"
}
statusText := "Saved"
if TheState.Editor.IsSaving() {
statusText = "Saving..."
} else if TheState.Editor.WriteFailed() {
statusText = "Error"
} else if TheState.Editor.IsDirty() {
statusText = "Modified"
}
// Compute cursor position and file size for the bottom bar.
cursorPos := TheState.Editor.CursorPosition
var fileSize int
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
fileSize = int(cb.FileLen())
} else {
fileSize = len(TheState.Editor.Buffer)
}
cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize)
bottomBar := ui.NewContainer(
bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
{Gesture: ui.Tap, Handler: ToggleWordWrap},
}),
},
)
// --- Editor text area ---
editorY := statusBarRegion.Y + statusBarRegion.H
editorH := bottomBarRegion.Y - editorY
editorRegion := ui.Region{
X: margin, Y: editorY,
W: screenWidth - margin*2,
H: editorH,
}
// 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
visibleCursorPos = TheState.Editor.CursorPosition - start
if visibleCursorPos < 0 {
visibleCursorPos = 0
}
// Adjust scroll offset to be relative to visibleContent origin
// Sub-line shift for the renderer: the SAME visual-line decomposition
// the window start used (VisibleByteRange above), so the drawn
// geometry and the window's content lines agree for every scroll
// offset and wrap state. scrollVisualDecompose re-derives it from the
// same inputs; the two agree by construction (see its doc).
_, subLine := scrollVisualDecompose()
visibleScrollOffset = ui.Dp(subLine)
// Map scroll offset to a chunk index
// Estimate line-to-byte conversion if LineIndex is missing
var scrollByteOffset int
if li := cb.LineIndex; li != nil {
// Precise: same decomposition as the window start above.
prefetchLine := winLine
if prefetchLine < li.LineCount() {
scrollByteOffset = li.ByteOffset(prefetchLine)
} else {
scrollByteOffset = int(cb.FileLen())
}
} else {
// Estimate
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EffectiveLineHeight())
scrollByteOffset = prefetchLine * 50
}
scrollChunk := scrollByteOffset / cb.ChunkSize()
// Use a smaller radius to prevent overloading the worker pool.
// Content() will load chunks immediately needed by the viewport.
cb.Prefetch(scrollChunk, 1)
} else {
// Fallback: no chunked buffer, use full buffer (small files)
visibleContent = TheState.Editor.Buffer
visibleCursorPos = TheState.Editor.CursorPosition
visibleScrollOffset = TheState.ScrollOffset
}
// Record the shaped visible range for the profiler probe (confirms the
// virtual-scroll window stays viewport-bounded, not the whole file).
TheState.VisibleStart = start
TheState.VisibleEnd = end
// Record the visible window for IME: the snippet is this window, so an
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
// to address the buffer. start is 0 for small (string) files, so the
// window is the whole buffer there.
TheState.Editor.IMEWindowStartByte = start
TheState.Editor.IMEWindowText = visibleContent
// Window-relative selection for the TextField (byte offsets into
// visibleContent); -1 means nothing visible is selected. The IME push and
// the in-app highlight consume this; the logic side keeps absolute
// offsets in EditorState.
windowSelStart, windowSelEnd := -1, -1
if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss {
ws, we := ss-start, TheState.Editor.SelectionEnd-start
if ws < 0 {
ws = 0
}
if we > len(visibleContent) {
we = len(visibleContent)
}
if ws < we {
windowSelStart, windowSelEnd = ws, we
}
}
// Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField(
"editor_text",
visibleContent,
editorRegion,
editorRegion.W,
visibleScrollOffset,
visibleCursorPos,
windowSelStart,
windowSelEnd,
[]ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
// 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},
},
)
// 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
elems := []ui.Element{statusBar, editorElem, bottomBar}
// The selection menu is added last so it draws on top of the editor.
// Re-anchor it to the selection's live screen position every frame so it
// follows the text while scrolling (it used to stay where it was first
// shown, detaching from the selection).
if TheState.Editor.MenuVisible {
if !positionSelectionMenu(&TheState.Editor) {
hideSelectionMenu()
}
}
if TheState.Editor.MenuVisible {
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
if pt, ok := data.(ui.Point); ok {
HandleMenuTap(pt.X, pt.Y)
}
}))
}
return elems
}
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
// window-relative coordinate space of the GlyphLayout, where layout.Y==0 is the
// top of the VISIBLE WINDOW, not the top of the file. It adds only the sub-line
// remainder of the scroll (ScrollOffset mod lineHeight), never the full scroll:
// the full scroll would make visualLine a huge content-line number far past the
// window's line count, clamping the cursor to the bottom line of the viewport on
// any large file (it only worked by luck on small files whose window spanned the
// tapped content-line number).
// scrollDecompose splits a scroll offset s into content line k and sub-line
// remainder r such that k*lh <= s < (k+1)*lh — the floor decomposition in the
// Dp domain, computed in float64.
//
// k and r must be the SINGLE shared source of both the window start line
// (visibleByteRangePrecise/Estimate) and the sub-line draw/tap remainder
// (visibleScrollOffset in layoutFrame, tapLocalY). Computing k as
// int(s/lh) in the Dp float32 domain can round the quotient UP across an
// integer boundary while the (float64) remainder still reflects the line
// below; the two bookkeeping values then disagree by one line in a
// sub-pixel-wide band of scroll offsets, shifting the rendered window — and
// with it every tapped content line — by one. The float64 decomposition with
// the r<0 / r>=lh corrections keeps k and r consistent for every s >= 0.
// scrollVisualDecompose maps the current scroll offset to (windowStartLine,
// drawOffset) in visual-line space — the exact inverse of what the renderer
// does when it draws the window at reg.Y - drawOffset.
//
// v0 is the visual line at the viewport top; k is the logical line that
// contains it (via the WrapIndex); drawOffset = ScrollOffset - V(k)*lh, the
// amount the renderer shifts the window up. With word wrap, drawOffset may
// exceed one lineHeight: the viewport top then sits inside a wrapped line's
// continuation, and the wrapped lines that fall above the viewport are
// clipped away. Without a WrapIndex (index not built yet) or with an
// all-ones index (nothing shaped/wrapped yet), the result is exactly the
// legacy mapping (k = v0, drawOffset = ScrollOffset mod lh).
//
// Must be called on the logic goroutine (reads ScrollOffset, the last
// GlyphLayout and the buffer's WrapIndex; writes nothing).
func scrollVisualDecompose() (k int, r float64) {
lh := EffectiveLineHeight()
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
v0, r0 := scrollDecompose(TheState.ScrollOffset, lh)
w := (*WrapIndex)(nil)
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
w = cb.WrapIndex
}
if w == nil {
return v0, r0
}
k = w.LineForVisual(int32(v0))
vk := w.VisualsBefore(k)
// r = s - V(k)*lh = (v0 - V(k))*lh + r0: the v0 - V(k) term counts the
// wrapped continuation lines above the window start.
return k, float64(v0-int(vk))*float64(lh) + r0
}
// applyWrapCounts corrects the WrapIndex counts for the lines described by
// a shaped layout. The layout's VisualLineStarts (window-relative byte
// offsets, one per visual line start) are grouped by the logical line whose
// byte range contains each start; a wrapped logical line then carries its
// true visual-line count.
//
// The layout describes the window it was shaped for (fb.WindowStartLine /
// fb.WindowStartByte), which may differ from the CURRENT window (a scroll
// can move the window between shaping and delivery) — that is fine: the
// counts belong to real lines that are still valid as long as no edit has
// shifted them, which the caller checks via EditSeq before calling here.
//
// A logical line whose range contains no visual line start (an empty line,
// or a line the shaper produced no starts for) keeps its current count.
//
// Must be called on the logic goroutine.
func (s *State) applyWrapCounts(fb ui.LayoutFeedback) {
cb := s.Editor.ChunkedBuffer
if cb == nil || cb.WrapIndex == nil || fb.WindowStartLine < 0 {
return
}
// fb.WindowText is the exact text this layout was shaped for (carried in
// the frame). Grouping over the CURRENT window (IMEWindowText) instead
// would attribute counts to the wrong lines whenever a scroll moved the
// window between shaping and delivery.
winText := fb.WindowText
if winText == "" {
return
}
starts := fb.GlyphLayout.VisualLineStarts
if len(starts) == 0 {
return
}
// Walk the window's logical lines (delimited by '\n'); attribute each
// visual line start to the logical line containing it. Both the starts
// and the line ranges are ascending, so a single forward pointer works.
si := 0
lineStart := 0
for li := 0; ; li++ {
idx := strings.IndexByte(winText[lineStart:], '\n')
lineEnd := len(winText)
last := false
if idx >= 0 {
lineEnd = lineStart + idx + 1
} else {
last = true
}
count := 0
for si < len(starts) && int(starts[si]) < lineEnd {
if int(starts[si]) >= lineStart {
count++
}
si++
}
if count > 0 {
cb.WrapIndex.Set(fb.WindowStartLine+li, int32(count))
}
if last {
break
}
lineStart = lineEnd
}
}
func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
sf, lf := float64(s), float64(lh)
if lf <= 0 {
return 0, 0
}
k = int(sf / lf)
r = sf - float64(k)*lf
if r < 0 {
k--
r = sf - float64(k)*lf
}
if r >= lf {
k++
r = sf - float64(k)*lf
}
if k < 0 {
k, r = 0, sf
}
return k, r
}
// tapLocalY returns the text-local Y (Dp, window-relative: 0 = top of the
// visible window, matching GlyphLayout.Y) of an app-local tap at ptY.
//
// The renderer draws the window's top at reg.Y - r' (r' = the sub-line draw
// offset from scrollVisualDecompose), so a point at app-Y ptY sits at
// window-Y (ptY - regionTop) + r'. r' is the same value the renderer was
// given, so the tap maps to the drawn geometry for every scroll offset,
// font setting and wrap state — including the case where the viewport top
// sits inside a wrapped line's continuation (r' > one line).
func tapLocalY(ptY, regionTopY ui.Dp) float64 {
_, r := scrollVisualDecompose()
return float64(ptY-regionTopY) + r
}
// SetCursorFromPoint updates the cursor position based on text-local
// coordinates (Dp, window-relative: x from the text region's left edge, y as
// produced by tapLocalY). A tap is an explicit cursor placement: it always
// clears any selection.
func SetCursorFromPoint(x, y float64) {
ClearSelection()
if pos, ok := textPosFromLocalPoint(x, y); ok {
TheState.Editor.CursorPosition = pos
}
}
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
// an absolute byte offset in the buffer. ok=false when the layout is empty or
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
// selection-drag handlers.
func textPosFromLocalPoint(x, y float64) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return 0, false
}
lineHeight := float64(EffectiveLineHeight())
// 1. Identify the intended line index based on y
// layout.Y values are relative to the text region origin.
// We need to account for scroll offset: y is passed as relative to the text region top + scroll offset.
// So y is the position in the *content*.
visualLine := int(y / lineHeight)
return textPosOnLineAtX(visualLine, x)
}
// textPosOnLineAtX maps a text-local x (Dp, same convention as
// textPosFromLocalPoint) on the given visual line to an absolute byte
// offset. Out-of-range lines clamp to the nearest non-empty line, exactly
// like the y-based selection in textPosFromLocalPoint. ok=false when the
// layout is empty or the (clamped) line has no glyphs.
func textPosOnLineAtX(visualLine int, x float64) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return 0, false
}
base := glyphBase()
lineHeight := float64(EffectiveLineHeight())
// Group glyphs by their Y-baseline
type lineGroup struct {
y float64
indices []int
}
groups := []lineGroup{}
// Find all unique baseline Ys
// The tap Y is based on line height (top of line).
// We need to associate Y-baseline with visual line index.
// Create map from visual line (0, 1, 2...) to baseline Y.
// Since line height is fixed:
// Line 0 baseline is at some Y0.
// Line 1 baseline is at Y0 + lineHeight.
// Let's find Y0 first.
minY := 1e9
for _, yVal := range layout.Y {
if float64(yVal) < minY {
minY = float64(yVal)
}
}
// Now group by baseline
groups = []lineGroup{}
for i, yVal := range layout.Y {
yFloat := float64(yVal)
lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line
if lineIdx < 0 {
lineIdx = 0
}
// Ensure enough groups
for len(groups) <= lineIdx {
groups = append(groups, lineGroup{
y: minY + float64(len(groups))*lineHeight,
indices: []int{},
})
}
groups[lineIdx].indices = append(groups[lineIdx].indices, i)
}
// If visualLine is out of bounds, clamp
if visualLine < 0 {
visualLine = 0
}
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
// Clamp to last valid group that has indices
for i := len(groups) - 1; i >= 0; i-- {
if len(groups[i].indices) > 0 {
visualLine = i
break
}
}
}
if len(groups[visualLine].indices) == 0 {
return 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
}
}
// 4. Check if tap is to the right of the last character
if rightmostIdx != -1 && x > rightmostX {
// Position at the end of the line content, before any trailing newline.
start := base + layout.ByteOffsets[rightmostIdx]
buf := TheState.Editor.ChunkedBuffer
var fileContent string
if buf != nil {
content, err := buf.FullContent()
if err != nil {
log.Printf("Error getting full content: %v", err)
return 0, false
}
fileContent = content
} else {
fileContent = TheState.Editor.Buffer
}
r, size := utf8.DecodeRuneInString(fileContent[start:])
if r == '\n' {
return start, true
} else {
return start + size, true
}
}
// 5. Otherwise, find the closest glyph on this line.
bestIdx := -1
minDist := float64(1e9)
for _, i := range targetGroup.indices {
if bestIdx == -1 {
bestIdx = i
}
// Calculate distance to the glyph center
glyphCenterX := float64(layout.X[i] + layout.Advance[i]/2)
dist := glyphCenterX - x
if dist < 0 {
dist = -dist
}
if dist < minDist {
minDist = dist
bestIdx = i
}
}
if bestIdx != -1 {
return base + layout.ByteOffsets[bestIdx], true
}
return 0, false
}