After the selection-replacement autocorrect on the phone, Gboard's local text was out of sync with ours and it re-sent the same empty fix-up commit in an endless loop (~one per 150 ms, each drift-snapped to the caret and applied as a no-op). The file was never damaged, but the IME never converged because it kept 'fixing' text that did not exist in its own model. The app cannot see the IME's model; the only recovery the IME contract offers is a restartInput, which makes it re-fetch the real text and selection around the caret. Arm that recovery automatically: three consecutive anomalous commits (drift-snapped, or empty text) set IMEForceResync, and the next frame ships the snippet trimmed by one rune, which changes the pushed text and forces the restart. The streak resets on any normal commit, so isolated anomalies never trigger it, and the resync is one-shot. TestRealFile_IMEForceResync pins the arm/reset/consume cycle.
3263 lines
112 KiB
Go
3263 lines
112 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
|
|
|
|
// IME snippet window (byte offsets, hysteresis-gated — see
|
|
// computeIMESnippetWindow). Decoupled from the render window: the render
|
|
// window follows the viewport (keyboard show/hide resizes it), while the
|
|
// IME snippet must be STABLE so a tap that re-centers the render window
|
|
// does not re-push the snippet. A snippet re-push reaches Gboard as
|
|
// imm.restartInput, which starts a fresh input session and capitalizes
|
|
// the first committed character even mid-word. The snippet re-anchors
|
|
// only when the caret leaves the window margins.
|
|
imeSnipWS int
|
|
imeSnipWE int
|
|
imeSnipArmed bool
|
|
IMESnippetText string
|
|
IMESnippetStartRune int
|
|
IMESnippetEndRune int
|
|
IMECaretRune int
|
|
IMESelStartRune int
|
|
IMESelEndRune int
|
|
// IMEForceResync asks the next frame to re-push the snippet trimmed by
|
|
// one rune (a forced restartInput): after several consecutive
|
|
// anomalous commits (drift-snapped or empty) the IME's local text is
|
|
// desynchronized from ours and it keeps re-sending the same fix (an
|
|
// endless empty-commit loop). A forced restart makes it re-fetch the
|
|
// real text and selection around the caret, which heals the model.
|
|
// Set by HandleIMECommit, consumed by the next frame (EditorLayout).
|
|
IMEForceResync bool
|
|
// imeAnomStreak counts consecutive anomalous IME commits (see above).
|
|
imeAnomStreak 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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).
|
|
// computeIMESnippetWindow maintains the hysteresis-gated IME snippet window
|
|
// (see the imeSnip* fields) and fills IMESnippetText / IMESnippet*Rune /
|
|
// IMECaretRune / IMESel* for the renderer to push. Must run on the logic
|
|
// goroutine during layout.
|
|
//
|
|
// The window is [32 KB] around the caret and re-anchors only when the caret
|
|
// is within [4 KB] of an edge (a screenful of flings stays inside, so
|
|
// fling-tap-type does not restart the IME session; only a genuinely distant
|
|
// tap or file switch re-anchors). Consequences:
|
|
// - typing inside the window never re-anchors (the frame's snippet text
|
|
// changes by the commit, which ApplyIMECommitToModel has already pushed
|
|
// - FlushIME dedupes and no restartInput is sent per keystroke);
|
|
// - a tap/caret move within the margins does not re-anchor: Gboard keeps
|
|
// its input session, so the first character after a tap is lowercase
|
|
// even mid-word (a render-window snippet re-anchored on every viewport
|
|
// change, resetting Gboard to a fresh session that caps the first
|
|
// character);
|
|
// - scrolls (flings) do not move the caret, so they never re-anchor: the
|
|
// IME stays anchored to the caret even while it is off-screen, which is
|
|
// what commit targeting needs.
|
|
//
|
|
// Edits shift the byte window silently (the text is re-read fresh every
|
|
// frame); the drift self-corrects at the next re-anchor.
|
|
func (s *State) computeIMESnippetWindow() {
|
|
const snipSize, snipMargin = 32768, 4096
|
|
caret := s.Editor.CursorPosition
|
|
if s.Editor.imeSnipArmed {
|
|
if caret < s.Editor.imeSnipWS+snipMargin || caret > s.Editor.imeSnipWE-snipMargin {
|
|
s.Editor.imeSnipArmed = false
|
|
}
|
|
}
|
|
if !s.Editor.imeSnipArmed {
|
|
ws := caret - snipSize/2
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
s.Editor.imeSnipWS = ws
|
|
s.Editor.imeSnipWE = ws + snipSize
|
|
s.Editor.imeSnipArmed = true
|
|
}
|
|
if end := int(s.fileLenNow()); end > 0 && s.Editor.imeSnipWE > end {
|
|
s.Editor.imeSnipWE = end
|
|
}
|
|
text := s.fileContent(s.Editor.imeSnipWS, s.Editor.imeSnipWE)
|
|
startRune := s.imeRuneOffsetAt(s.Editor.imeSnipWS)
|
|
s.Editor.IMESnippetText = text
|
|
s.Editor.IMESnippetStartRune = startRune
|
|
s.Editor.IMESnippetEndRune = startRune + utf8.RuneCountInString(text)
|
|
s.Editor.IMECaretRune = s.imeRuneOffsetAt(caret)
|
|
if selActive() {
|
|
ss, se := s.Editor.SelectionStart, s.Editor.SelectionEnd
|
|
if ss < s.Editor.imeSnipWS {
|
|
ss = s.Editor.imeSnipWS
|
|
}
|
|
if se > s.Editor.imeSnipWE {
|
|
se = s.Editor.imeSnipWE
|
|
}
|
|
r0 := s.imeRuneOffsetAt(ss)
|
|
s.Editor.IMESelStartRune = r0
|
|
s.Editor.IMESelEndRune = r0 + utf8.RuneCountInString(s.fileContent(ss, se))
|
|
} else {
|
|
s.Editor.IMESelStartRune = -1
|
|
s.Editor.IMESelEndRune = -1
|
|
}
|
|
}
|
|
|
|
// fileLenNow returns the current content length (chunked or string buffer).
|
|
func (s *State) fileLenNow() int64 {
|
|
if cb := s.Editor.ChunkedBuffer; cb != nil {
|
|
return cb.fileLen
|
|
}
|
|
return int64(len(s.Editor.Buffer))
|
|
}
|
|
|
|
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
|
|
}
|
|
snapped := false
|
|
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
|
|
snapped = true
|
|
}
|
|
}
|
|
// Anomalous commits (snapped, or empty: an IME fix-up that changes
|
|
// nothing) mean the IME's local text has desynchronized from ours.
|
|
// After a few in a row, force a re-syncing snippet re-push on the next
|
|
// frame (see IMEForceResync) so the IME re-fetches the real text and
|
|
// the loop ends; a normal commit resets the streak.
|
|
if snapped || c.Text == "" {
|
|
TheState.Editor.imeAnomStreak++
|
|
if TheState.Editor.imeAnomStreak >= 3 {
|
|
TheState.Editor.imeAnomStreak = 0
|
|
TheState.Editor.IMEForceResync = true
|
|
}
|
|
} else {
|
|
TheState.Editor.imeAnomStreak = 0
|
|
}
|
|
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.computeIMESnippetWindow()
|
|
// 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.IMESnippetText = TheState.Editor.IMESnippetText
|
|
editorElem.IMESnippetStartRune = TheState.Editor.IMESnippetStartRune
|
|
editorElem.IMESnippetEndRune = TheState.Editor.IMESnippetEndRune
|
|
editorElem.IMECaretRune = TheState.Editor.IMECaretRune
|
|
editorElem.IMESelStartRune = TheState.Editor.IMESelStartRune
|
|
editorElem.IMESelEndRune = TheState.Editor.IMESelEndRune
|
|
editorElem.IMEForceResync = TheState.Editor.IMEForceResync
|
|
TheState.Editor.IMEForceResync = false
|
|
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()
|
|
}
|