On device (Pixel 9 Pro) the relaunch landed further UP than the saved position: while the restore scroll is still armed (scale + size + content can take ~600 ms), the top-of-file window is what gets rendered, and its shaping feedback — real wrap counts for lines ABOVE the restored line — lands before or just after the offset is applied. The counts are correct data, but they change V(line), so the line-derived offset (synthesized for the all-estimate index) maps to a shallower line and the viewport drifts up; the save then persists the drifted line and every subsequent relaunch lands there. Fix: the restore pins the logical line. Until the restored window's own shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps), every accepted wrap correction re-derives the offset as VisualsBefore(line)*lh + sub under the corrected index. The pin refresh does not clamp to MaxScroll: the correction just grew the index, so the pre-layout clamp value is stale and would under-clamp the re-derived offset (the layout of the emitted frame clamps to the fresh value). Also: the apply-time offset is mapped through VisualsBefore under the current index (identical to line*lh while the index is all estimates), so pre-apply corrections are absorbed instead of gated away. Verified on device: saved line 420 -> restored 420 (was 333); saved 573 (near EOF, clamp territory) -> restored 573. New e2e regression TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late top-window feedback and fails pre-fix (window drifts 200 -> 66).
2647 lines
88 KiB
Go
2647 lines
88 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/io/key"
|
|
"pad/internal/browser"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// EditorFontSize is the font size used for editor text.
|
|
const EditorFontSize = 14 // unit.Sp
|
|
|
|
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
|
|
const EditorLineHeightScale = 1.2
|
|
|
|
// EditorLineHeight returns the fixed line height in Dp for the editor font.
|
|
func EditorLineHeight() ui.Dp {
|
|
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
|
|
}
|
|
|
|
// formatSize renders a byte count as a human-readable size (e.g. "10.4 MB").
|
|
func formatSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMG"[exp])
|
|
}
|
|
|
|
// Page identifies which page the app is showing.
|
|
type Page int
|
|
|
|
const (
|
|
BrowserPage Page = iota
|
|
EditorPage
|
|
)
|
|
|
|
// SortMode controls how the browser list is sorted.
|
|
type SortMode int
|
|
|
|
const (
|
|
SortByDateDesc SortMode = iota // default: newest first
|
|
SortByDateAsc
|
|
SortByNameAsc
|
|
SortByNameDesc
|
|
)
|
|
|
|
// EditorState holds all editor-specific state.
|
|
type EditorState struct {
|
|
Buffer string // DEPRECATED: use ChunkedBuffer for large files
|
|
ChunkedBuffer *ChunkedBuffer // NEW: chunked file access for virtual scrolling
|
|
CursorPosition int
|
|
GlyphLayout ui.GlyphLayout
|
|
SelectionStart int
|
|
SelectionEnd int
|
|
// SelectionAnchor is the fixed end of a shift-selection; CursorPosition is
|
|
// the active end. -1 means no shift-selection in progress. The effective
|
|
// selection is [min(anchor,cursor), max(anchor,cursor)]; SelectionStart/
|
|
// SelectionEnd are its cached normalized form (-1/-1 = none).
|
|
SelectionAnchor int
|
|
CursorVisible bool
|
|
// IMEWindowStartByte is the absolute byte offset in the buffer where the
|
|
// visible window (IMEWindowText) begins. For small (string) files it is 0
|
|
// and the window is the whole buffer; for large (chunked) files it is the
|
|
// viewport start. The IME snippet is the window, so an EditEvent.Range is
|
|
// relative to the window and must be offset by this to address the buffer.
|
|
IMEWindowStartByte int
|
|
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
|
// It is set during layout and is what an EditEvent.Range indexes into.
|
|
IMEWindowText string
|
|
// EditSeq counts content edits (incremented by markDirty). The shaped
|
|
// glyph layout arriving via layoutChan is only applied to the WrapIndex
|
|
// when its EditSeq matches, so a layout shaped before an edit can never
|
|
// stamp stale wrap counts onto shifted lines.
|
|
EditSeq uint64
|
|
// ShowIMESeq pulses whenever the logic layer wants the soft keyboard up
|
|
// (file open, tap/double-tap on the editor). The renderer issues
|
|
// SoftKeyboardCmd{Show:true} only on a change (see TextField.ShowIMESeq),
|
|
// so a user-dismissed keyboard stays down until the next pulse.
|
|
ShowIMESeq uint64
|
|
// --- Touch selection (v1) ---
|
|
// CaretDrag: after a long press on blank space a single draggable caret
|
|
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
|
|
// floating copy/cut/paste menu (positioned below the line of the selection
|
|
// end; items are recomputed when the menu is shown). SelDragging/
|
|
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
|
|
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
|
|
// handle). All of it is logic-owned; the renderer only reports finger
|
|
// positions and draws the geometry.
|
|
CaretDrag bool
|
|
MenuVisible bool
|
|
MenuRect ui.Region
|
|
MenuItems []ui.MenuItem
|
|
SelDragging bool
|
|
SelDragWhich int
|
|
SelDragRel int
|
|
// SelDragSwapped: during a start/end-handle drag the finger crossed the
|
|
// opposite handle and the selection was flipped (native behaviour): the
|
|
// dragged handle now controls the end it crossed. Cleared when the finger
|
|
// crosses back or the drag ends.
|
|
SelDragSwapped bool
|
|
// SelDragPressX/Y is the text-local position of the first event of an
|
|
// in-progress handle/caret drag (the grab), and SelDragAnchorX/Y the
|
|
// text-local point of the dragged anchor AT THE GRAB (see
|
|
// textPointLocal). Each later event moves the anchor by the finger's
|
|
// displacement from the grab, so the handle tracks the finger 1:1 (the
|
|
// teardrop stays under the finger) and the selection resizes
|
|
// continuously — no line snapping, no jump on grab. The reference is
|
|
// fixed at the grab, so the anchor's own movement cannot feed back into
|
|
// its target: a jittering finger near a line boundary cannot race the
|
|
// anchor off the screen.
|
|
SelDragPressX float64
|
|
SelDragPressY float64
|
|
SelDragAnchorX float64
|
|
SelDragAnchorY float64
|
|
// Find is the in-file search state (find bar); see search.go.
|
|
Find FindState
|
|
Filename string
|
|
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
|
// editor shows a "too large to edit" notice instead of content (the
|
|
// browser can still list the file).
|
|
TooLarge bool
|
|
TooLargeSize int64
|
|
fileVersion map[string]int
|
|
lastWriteVersion map[string]int
|
|
saveTimer *time.Timer
|
|
writeFailed map[string]bool // Added: tracks failed writes for UI
|
|
retryAttempts map[string]int // Added: tracks retry attempts
|
|
}
|
|
|
|
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
|
|
func (e *EditorState) GetBuffer() string {
|
|
if e.ChunkedBuffer != nil {
|
|
fullContent, err := e.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full buffer content: %v", err)
|
|
return ""
|
|
}
|
|
return fullContent
|
|
}
|
|
return e.Buffer
|
|
}
|
|
|
|
// IsSaving returns true if a save is pending.
|
|
func (e *EditorState) IsSaving() bool {
|
|
return e.saveTimer != nil
|
|
}
|
|
|
|
// IsDirty, WriteFailed, and RetryAttempts are computed:
|
|
|
|
func (e *EditorState) IsDirty() bool {
|
|
bufVer := e.fileVersion[e.Filename]
|
|
writeVer := e.lastWriteVersion[e.Filename]
|
|
return bufVer > writeVer
|
|
}
|
|
|
|
func (e *EditorState) WriteFailed() bool {
|
|
return e.writeFailed[e.Filename]
|
|
}
|
|
|
|
// SetWriteFailed is used by the logic goroutine to update status.
|
|
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
|
|
e.writeFailed[filename] = failed
|
|
if !failed {
|
|
e.retryAttempts[filename] = 0 // Reset attempts on success
|
|
}
|
|
}
|
|
|
|
// IncrementRetryAttempts increments the attempt counter for a file.
|
|
func (e *EditorState) IncrementRetryAttempts(filename string) int {
|
|
e.retryAttempts[filename]++
|
|
return e.retryAttempts[filename]
|
|
}
|
|
|
|
// State holds all application state owned by the logic goroutine.
|
|
type State struct {
|
|
PixelWidth int // raw pixel width from Gio ConfigEvent
|
|
PixelHeight int // raw pixel height from Gio ConfigEvent
|
|
scale float32
|
|
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
|
|
page Page // current page (Browser or Editor)
|
|
WordWrap bool
|
|
ScrollOffset ui.Dp // vertical scroll position in Dp
|
|
ByteOffset int // Byte offset of the first visible line
|
|
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
|
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
|
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
|
|
// for the viewport. Set by EditorLayout each frame; read by the profiler
|
|
// probe to confirm the shaped range stays viewport-bounded (not the file).
|
|
VisibleStart int
|
|
VisibleEnd int
|
|
// WindowStartLine is the logical line the visible editor window starts
|
|
// at, for the last computed layout (-1 when no editor window is shown).
|
|
// It is shipped with the shaped glyph layout (Frame) so the layout
|
|
// correlation pass applies wrap counts to the lines that layout
|
|
// describes, not the current window (a scroll may have moved it).
|
|
WindowStartLine int
|
|
FocusedElementID string // ID of the currently focused element
|
|
Elems []ui.Element
|
|
lastEvictionTime time.Time // Throttles chunk eviction
|
|
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
|
|
// EditorRegion is the editor text region in app-local Dp, recomputed on
|
|
// every layout. Input handlers (tap, long press, selection drags) convert
|
|
// app-local points to text-local coordinates through it.
|
|
EditorRegion ui.Region
|
|
// Clipboard channels (touch-selection menu). ClipboardSetChan and
|
|
// PasteReqChan are consumed by the main goroutine, which executes the Gio
|
|
// clipboard ops and reports the read result back on PasteChan. Buffered so
|
|
// a harness without a main loop never blocks the logic goroutine.
|
|
clipboardSetChan chan string
|
|
pasteReqChan chan struct{}
|
|
pasteChan chan string
|
|
// Browser state (directly embedded per architecture §8)
|
|
Browser browser.BrowserState // Embedded, not a pointer
|
|
// Editor state
|
|
Editor EditorState // New field
|
|
open func(string)
|
|
}
|
|
|
|
func NewState() *State {
|
|
return &State{
|
|
scale: 1.0,
|
|
page: BrowserPage, // Reverted to BrowserPage
|
|
WordWrap: true, // Enable word wrap by default
|
|
lastEvictionTime: time.Now(),
|
|
Browser: *browser.NewBrowserState(),
|
|
Editor: EditorState{
|
|
CursorPosition: 0,
|
|
SelectionStart: -1,
|
|
SelectionEnd: -1,
|
|
SelectionAnchor: -1,
|
|
fileVersion: make(map[string]int),
|
|
lastWriteVersion: make(map[string]int),
|
|
writeFailed: make(map[string]bool),
|
|
retryAttempts: make(map[string]int),
|
|
},
|
|
clipboardSetChan: make(chan string, 16),
|
|
pasteReqChan: make(chan struct{}, 16),
|
|
pasteChan: make(chan string, 16),
|
|
}
|
|
}
|
|
|
|
func (s *State) SetScale(scale float32) {
|
|
s.scale = scale
|
|
}
|
|
|
|
func (s *State) SetFontScale(fs float32) {
|
|
s.fontScale = fs
|
|
}
|
|
|
|
// Page returns the current page (BrowserPage or EditorPage).
|
|
func (s *State) Page() Page {
|
|
return s.page
|
|
}
|
|
|
|
func (s *State) Scale() float32 {
|
|
return s.scale
|
|
}
|
|
|
|
// stateFontScale returns the user font-size setting (1.0 when unknown).
|
|
func stateFontScale() float32 {
|
|
if TheState != nil && TheState.fontScale > 0 {
|
|
return TheState.fontScale
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// EffectiveLineHeight is the editor line height in density-dp WITH the user
|
|
// font-size setting applied. The shaper draws baselines at
|
|
// Sp(EditorFontSize*LineHeightScale) physical px, which is
|
|
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
|
|
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
|
|
// clamping, cursor vertical move) must use this value rather than the raw
|
|
// EditorLineHeight; at a non-default font setting the two differ by the
|
|
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
|
|
// of lines and make scroll clamping stop short of (or run past) the file
|
|
// ends.
|
|
func EffectiveLineHeight() ui.Dp {
|
|
return EffectiveLineHeightAt(stateFontScale())
|
|
}
|
|
|
|
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
|
|
// (used where the live State value is not the right source, e.g. tests).
|
|
func EffectiveLineHeightAt(fs float32) ui.Dp {
|
|
if fs <= 0 {
|
|
fs = 1
|
|
}
|
|
return ui.Dp(float64(EditorLineHeight()) * float64(fs))
|
|
}
|
|
|
|
// layout converts stored pixel dimensions to Dp using the current scale
|
|
// and computes the element tree. Called only when a frame is needed.
|
|
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
|
// not here, to ensure proper channel-based state flow.
|
|
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
|
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
|
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
|
|
|
// Calculate VisibleCount before laying out the browser page.
|
|
// This ensures the browser shows entries based on the current viewport.
|
|
if s.PixelHeight > 0 && s.scale > 0 {
|
|
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
|
|
rowHeight := ui.Dp(48)
|
|
newVisibleCount := int(listAreaHeight / rowHeight)
|
|
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
|
|
s.Browser.VisibleCount = newVisibleCount
|
|
}
|
|
}
|
|
|
|
switch s.page {
|
|
case BrowserPage:
|
|
tapHandler := func(data any) {
|
|
if idx, ok := data.(int); ok {
|
|
browser.HandleBrowserTap(bm, &s.Browser, idx)
|
|
}
|
|
}
|
|
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
|
|
case EditorPage:
|
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
|
}
|
|
return s.Elems
|
|
}
|
|
|
|
// ToggleWordWrap toggles the word wrap setting.
|
|
func ToggleWordWrap(data any) {
|
|
TheState.WordWrap = !TheState.WordWrap
|
|
}
|
|
|
|
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
|
|
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
|
// Also evicts chunks far from the cursor to keep memory bounded.
|
|
func HandleScroll(data any) {
|
|
// Swallow scroll from the opening gesture: the tap that opened the file can
|
|
// leak a scroll delta into the editor before the line index is built (when
|
|
// MaxScroll is a large estimate), leaving the viewport past the content.
|
|
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
|
return
|
|
}
|
|
delta := data.(int) // pixels
|
|
if TheLogic != nil {
|
|
TheLogic.releaseRestorePin() // the user takes over the viewport
|
|
}
|
|
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
// Evict chunks far from the cursor to keep memory bounded.
|
|
// Only evict when the cursor is near the viewport (i.e., scrolled to top).
|
|
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
|
|
// Temporarily disabled eviction to debug thrashing issues.
|
|
/*
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
|
|
cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
|
|
TheState.lastEvictionTime = time.Now()
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
|
|
func HandleBrowserScroll(data any) {
|
|
delta := data.(int) // pixel delta from gesture.Scroll.Update
|
|
browser.HandlePixelScroll(&TheState.Browser, delta)
|
|
}
|
|
|
|
// GoToBrowser switches the app to the browser page.
|
|
func GoToBrowser(data any) {
|
|
if TheLogic != nil {
|
|
TheLogic.FlushAll()
|
|
}
|
|
TheState.Editor.findClose()
|
|
TheState.page = BrowserPage
|
|
}
|
|
|
|
// GoToEditor switches the app to the editor page.
|
|
func GoToEditor(data any) {
|
|
TheState.page = EditorPage
|
|
}
|
|
|
|
// OpenFile sets the active filename and switches to the editor page.
|
|
// data is the filename string from the browser list.
|
|
func OpenFile(data any) {
|
|
filename := data.(string)
|
|
// NOTE: the external open hook (TheState.open, e.g. the Android Termux
|
|
// bridge) is intentionally NOT called here. Opening a file is an in-app
|
|
// action; the external bridge was the old tap path and it crashes on
|
|
// Android 7+ (FileUriExposedException from a file:// Intent URI).
|
|
|
|
// Dispatch a request to load the file
|
|
go func() {
|
|
TheLogic.openFileChan <- filename
|
|
}()
|
|
|
|
TheState.Editor.Filename = filename
|
|
TheState.Editor.CursorPosition = 0 // Reset cursor to top
|
|
TheState.Editor.TooLarge = false
|
|
TheState.Editor.TooLargeSize = 0
|
|
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
|
|
TheState.page = EditorPage
|
|
TheState.FocusedElementID = "editor_text" // Set focus to editor
|
|
TheState.Editor.ShowIMESeq++ // raise the keyboard for the newly opened file
|
|
// The tap that opened this file is delivered to the browser row, but its
|
|
// gesture can leak into the now-visible editor and move the cursor/scroll.
|
|
// Record the open time so the editor can swallow taps in a short window
|
|
// right after open (the opening tap, not a deliberate editor tap).
|
|
TheState.justOpenedAt = time.Now()
|
|
}
|
|
|
|
// SetChunkedBuffer sets the chunked buffer for the current editor state.
|
|
func SetChunkedBuffer(cb *ChunkedBuffer) {
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
}
|
|
|
|
// ToggleSortOrder cycles the browser sort mode through four modes.
|
|
func ToggleSortOrder(data any) {
|
|
// Cycle through the 4 sort modes
|
|
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
|
|
// Reset scroll on sort change
|
|
TheState.Browser.ScrollOffset = 0
|
|
// Clear cached pages so they reload with the new sort order
|
|
TheState.Browser.Pages = make(map[int]*browser.Page)
|
|
// Reload initial pages with the new sort order
|
|
browser.LoadInitialPages(&TheState.Browser)
|
|
// Recompute search results if there's an active search query
|
|
browser.HandleSortModeChange(&TheState.Browser)
|
|
}
|
|
|
|
// HandleCursorMove updates the cursor position within bounds.
|
|
func HandleCursorMove(delta int) {
|
|
newPos := TheState.Editor.CursorPosition + delta
|
|
if newPos < 0 {
|
|
newPos = 0
|
|
}
|
|
// Use ChunkedBuffer.FileLen() as the authoritative upper bound
|
|
var maxPos int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
maxPos = int(cb.FileLen())
|
|
}
|
|
if maxPos == 0 {
|
|
// Fallback to Buffer length if ChunkedBuffer not yet initialized
|
|
maxPos = len(TheState.Editor.Buffer)
|
|
}
|
|
if newPos > maxPos {
|
|
newPos = maxPos
|
|
}
|
|
TheState.Editor.CursorPosition = newPos
|
|
}
|
|
|
|
// HandleKeyDown interprets keyboard events for navigation and editing.
|
|
// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with
|
|
// modifier state) or bare key.Name (legacy/test path, no modifiers).
|
|
func HandleKeyDown(data any) {
|
|
// A too-large file is not editable: ignore all key input.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
switch v := data.(type) {
|
|
case key.EditEvent:
|
|
// Text input from IME / keyboard.
|
|
if v.Text == "\b" {
|
|
// Backspace character (legacy / hardware): delete the selection if
|
|
// there is one, else one char before the cursor.
|
|
HandleBackspace()
|
|
} else {
|
|
// IME insert/replace/delete: replace [Range.Start, Range.End) with
|
|
// Text. Range is empty (start==end) for plain inserts; a non-empty
|
|
// range with text is a swipe/autocorrect replacement; a non-empty
|
|
// range with empty text is a range delete. Ignoring Range here is
|
|
// what caused replaced text to be duplicated. A live selection is
|
|
// always replaced (see HandleReplaceRange).
|
|
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
|
|
}
|
|
case ui.KeyEvent:
|
|
handleKey(v.Name, v.Shift)
|
|
case key.Name:
|
|
// Bare key name: no modifier state, so no shift-selection here.
|
|
handleKey(v, false)
|
|
}
|
|
}
|
|
|
|
// handleKey dispatches a key press. shift=true turns cursor moves into
|
|
// selection extensions; edit keys act on the selection when one is active.
|
|
func handleKey(name key.Name, shift bool) {
|
|
switch name {
|
|
case key.NameLeftArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(-1) })
|
|
case key.NameRightArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(1) })
|
|
case key.NameUpArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(true) })
|
|
case key.NameDownArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(false) })
|
|
case key.NameDeleteBackward:
|
|
HandleBackspace()
|
|
case key.NameDeleteForward:
|
|
HandleDelete()
|
|
case key.NameReturn:
|
|
HandleInsert("\n")
|
|
case key.NameHome:
|
|
moveCursor(shift, HandleHome)
|
|
case key.NameEnd:
|
|
moveCursor(shift, HandleEnd)
|
|
case key.NamePageUp:
|
|
moveCursor(shift, func() { HandlePageUpDown(true) })
|
|
case key.NamePageDown:
|
|
moveCursor(shift, func() { HandlePageUpDown(false) })
|
|
}
|
|
}
|
|
|
|
// glyphBase returns the absolute file byte offset at the start of the current
|
|
// visible window. The GlyphLayout is shaped from the visible window alone, so
|
|
// every one of its ByteOffsets is relative to this base; adding it yields an
|
|
// absolute file offset. For a whole-file window (small files, or scroll 0) the
|
|
// base is 0 and this is a no-op.
|
|
func glyphBase() int {
|
|
return TheState.Editor.IMEWindowStartByte
|
|
}
|
|
|
|
// --- Text selection ---------------------------------------------------------
|
|
//
|
|
// The selection is a byte range in absolute file coordinates derived from
|
|
// SelectionAnchor (fixed end) and CursorPosition (active end). No selection:
|
|
// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/
|
|
// end/page extend the selection; a plain cursor move or any edit clears it.
|
|
// All of these must run on the logic goroutine (owner).
|
|
|
|
// selActive reports whether there is a non-empty selection.
|
|
func selActive() bool {
|
|
e := &TheState.Editor
|
|
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
|
|
}
|
|
|
|
// ClearSelection drops the selection and the shift-anchor. It also dismisses
|
|
// the selection menu and cancels any in-progress drag bookkeeping: every path
|
|
// that clears the selection (tap, plain cursor move, any edit) should take
|
|
// the menu and handles down with it.
|
|
func ClearSelection() {
|
|
e := &TheState.Editor
|
|
e.SelectionAnchor = -1
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
e.MenuVisible = false
|
|
e.MenuItems = nil
|
|
e.SelDragging = false
|
|
}
|
|
|
|
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
|
// anchoring at the lower end and placing the cursor (active end) at the upper
|
|
// end. Clamped to the buffer; an empty/clamped range clears instead.
|
|
// Convenience for tests and future gestures (tap-range, double-tap).
|
|
func SetSelection(start, end int) {
|
|
e := &TheState.Editor
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
var fileLen int
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
fileLen = int(cb.FileLen())
|
|
} else {
|
|
fileLen = len(e.Buffer)
|
|
}
|
|
if end > fileLen {
|
|
end = fileLen
|
|
}
|
|
if end <= start {
|
|
ClearSelection()
|
|
e.CursorPosition = start
|
|
return
|
|
}
|
|
e.SelectionAnchor = start
|
|
e.SelectionStart = start
|
|
e.SelectionEnd = end
|
|
e.CursorPosition = end
|
|
}
|
|
|
|
// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor,
|
|
// cursor). A zero-length range (anchor == cursor) is treated as no selection,
|
|
// but the anchor is kept so the next shift-move extends from the original spot
|
|
// again.
|
|
func updateSelectionFromAnchor() {
|
|
e := &TheState.Editor
|
|
if e.SelectionAnchor < 0 {
|
|
ClearSelection()
|
|
return
|
|
}
|
|
a, c := e.SelectionAnchor, e.CursorPosition
|
|
if a == c {
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
return
|
|
}
|
|
if a < c {
|
|
e.SelectionStart, e.SelectionEnd = a, c
|
|
} else {
|
|
e.SelectionStart, e.SelectionEnd = c, a
|
|
}
|
|
}
|
|
|
|
// moveCursor runs op (a cursor-mutating handler). With shift held it keeps
|
|
// the anchor and extends the selection to the new cursor position; without it
|
|
// it clears any selection first. This is the single place where selection
|
|
// bookkeeping meets cursor movement, so every move path (arrows, home/end,
|
|
// page, vertical) gets consistent semantics.
|
|
func moveCursor(shift bool, op func()) {
|
|
if shift {
|
|
if TheState.Editor.SelectionAnchor < 0 {
|
|
TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition
|
|
}
|
|
} else {
|
|
ClearSelection()
|
|
}
|
|
op()
|
|
if shift {
|
|
updateSelectionFromAnchor()
|
|
}
|
|
}
|
|
|
|
// deleteRange removes the byte range [start, end) from the active buffer and
|
|
// updates the line index. Shared by the selection-aware edit handlers.
|
|
func deleteRange(start, end int) {
|
|
if end <= start {
|
|
return
|
|
}
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
buf.Delete(start, end-start)
|
|
buf.UpdateLineIndexAfterDelete(start, end)
|
|
} else {
|
|
str := TheState.Editor.Buffer
|
|
TheState.Editor.Buffer = str[:start] + str[end:]
|
|
}
|
|
TheState.Editor.findEdit(start, end, 0)
|
|
}
|
|
|
|
// --- Touch selection (v1) ---------------------------------------------------
|
|
//
|
|
// Android-style touch interaction, layered over the byte-range selection
|
|
// model above. Single tap = caret (existing). Long press = select the word
|
|
// under the finger (or a draggable caret handle on blank space). Double tap
|
|
// = select the word. Handles: drag start/end to resize, drag the body to move
|
|
// the whole selection. Floating menu: Copy / Cut / Paste.
|
|
//
|
|
// Coordinate flow: the renderer reports app-local window Dp points (the same
|
|
// space as a tap). The functions below convert to text-local coordinates via
|
|
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
|
|
// All of these run on the logic goroutine (owner).
|
|
|
|
const (
|
|
menuItemW = ui.Dp(56) // width of one selection-menu button
|
|
menuH = ui.Dp(52) // selection-menu panel height
|
|
// handleDropDp is how far below a visual line's bottom edge the selection
|
|
// handle's GRAB BOX extends: 10dp (handle radius, line bottom to handle
|
|
// centre) + 24dp (half the 48dp grab box). Keep in sync with the
|
|
// renderer's registerDrag geometry (internal/ui/render.go). A menu placed
|
|
// below a single-line selection sits this far below the line's bottom
|
|
// edge so it does not overlap the handles' grab boxes — the menu is drawn
|
|
// topmost (z-order) and would steal the handles' drags.
|
|
handleDropDp = 34
|
|
)
|
|
|
|
// isWordRune reports whether a rune is part of a selectable word (letters,
|
|
// digits, underscore).
|
|
func isWordRune(r rune) bool {
|
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
|
}
|
|
|
|
// windowContent returns a content accessor for the active buffer.
|
|
func windowContent() func(a, b int) string {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
return func(a, b int) string {
|
|
return cb.Content(a, b)
|
|
}
|
|
}
|
|
s := TheState.Editor.Buffer
|
|
return func(a, b int) string {
|
|
if a < 0 {
|
|
a = 0
|
|
}
|
|
if b > len(s) {
|
|
b = len(s)
|
|
}
|
|
if a >= b {
|
|
return ""
|
|
}
|
|
return s[a:b]
|
|
}
|
|
}
|
|
|
|
func fileLenBytes() int {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
return int(cb.FileLen())
|
|
}
|
|
return len(TheState.Editor.Buffer)
|
|
}
|
|
|
|
// wordRangeAt returns the byte range [start, end) of the word containing the
|
|
// rune at (or immediately before) pos. ok=false when neither is a word
|
|
// character (e.g. a space or punctuation).
|
|
func wordRangeAt(pos int) (start, end int, ok bool) {
|
|
content := windowContent()
|
|
fileLen := fileLenBytes()
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
if pos > fileLen {
|
|
pos = fileLen
|
|
}
|
|
// 256-byte window around pos; words longer than that are clipped (rare).
|
|
a := pos - 256
|
|
if a < 0 {
|
|
a = 0
|
|
}
|
|
b := pos + 256
|
|
if b > fileLen {
|
|
b = fileLen
|
|
}
|
|
w := content(a, b)
|
|
rp := pos - a
|
|
|
|
// Rune starting at rp, and rune ending at rp (if any).
|
|
rAt, szAt := utf8.DecodeRuneInString(w[rp:])
|
|
var rBefore rune
|
|
var szBefore int
|
|
if rp > 0 {
|
|
j := rp - 1
|
|
for j >= 0 && w[j]&0xC0 == 0x80 {
|
|
j--
|
|
}
|
|
if j >= 0 {
|
|
rBefore, szBefore = utf8.DecodeRuneInString(w[j:])
|
|
}
|
|
}
|
|
|
|
// expandWord grows [l, r) over consecutive word runes (byte offsets in w).
|
|
expandWord := func(l, r int) (int, int) {
|
|
for l > 0 {
|
|
j := l - 1
|
|
for j >= 0 && w[j]&0xC0 == 0x80 {
|
|
j--
|
|
}
|
|
if j < 0 {
|
|
break
|
|
}
|
|
r, _ := utf8.DecodeRuneInString(w[j:])
|
|
if !isWordRune(r) {
|
|
break
|
|
}
|
|
l = j
|
|
}
|
|
for r < len(w) {
|
|
rr, sz := utf8.DecodeRuneInString(w[r:])
|
|
if sz == 0 || !isWordRune(rr) {
|
|
break
|
|
}
|
|
r += sz
|
|
}
|
|
return l, r
|
|
}
|
|
|
|
if szAt > 0 && isWordRune(rAt) {
|
|
l, r := expandWord(rp, rp+szAt)
|
|
return a + l, a + r, true
|
|
}
|
|
if szBefore > 0 && isWordRune(rBefore) {
|
|
l, r := expandWord(rp-szBefore, rp)
|
|
return a + l, a + r, true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// pointInMenu reports whether the app-local Dp point is inside the visible
|
|
// selection menu panel.
|
|
func pointInMenu(x, y ui.Dp) bool {
|
|
r := TheState.Editor.MenuRect
|
|
return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
|
|
}
|
|
|
|
func hideSelectionMenu() {
|
|
e := &TheState.Editor
|
|
e.MenuVisible = false
|
|
e.MenuItems = nil
|
|
e.MenuRect = ui.Region{}
|
|
}
|
|
|
|
// positionSelectionMenu places the copy/cut/paste menu relative to the
|
|
// selection, mimicking the native Android selection toolbar:
|
|
//
|
|
// - Preferred: ABOVE the selection's top line. The menu is anchored to the
|
|
// STABLE end of the selection — SelectionStart, the end that does not
|
|
// move while the user drags the END handle — so the menu does not chase
|
|
// the moving handle and does not float inside a tall selection (anchoring
|
|
// to the end would drag the menu through the selected text when the end
|
|
// handle is dragged down). While the START handle is dragged, the start
|
|
// moves and the end is fixed, so it anchors to the end. With no
|
|
// selection (caret) it anchors to the caret.
|
|
// - Fallback: when there is no room above (the selection starts at the top
|
|
// of the window), BELOW the selection. For a single-line selection the
|
|
// menu sits below the selection HANDLES (they hang off the line's bottom
|
|
// edge): the menu is drawn last (top of the z-order) and would steal the
|
|
// handles' drags wherever it overlaps their grab boxes. For a multi-line
|
|
// selection it sits 8dp below the selection's bottom edge, like the
|
|
// native toolbar.
|
|
//
|
|
// The menu must not vanish while the selection is still on screen: when both
|
|
// ends of the selection are outside the shaped window but some part of the
|
|
// selection is still visible, the menu keeps its previous position clamped
|
|
// to the window. It reports false (and the caller hides it) only when the
|
|
// selection has left the window entirely. Re-anchoring (double-tap) re-shows
|
|
// it. It is called from showSelectionMenu and from EditorLayout on every
|
|
// frame while the menu is visible, so the menu tracks the selected text when
|
|
// the user scrolls: it is anchored to the TEXT (an absolute buffer offset
|
|
// mapped through the live screen geometry), not to the screen position where
|
|
// it was first shown.
|
|
func positionSelectionMenu(e *EditorState) bool {
|
|
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 || len(e.MenuItems) == 0 {
|
|
return false
|
|
}
|
|
anchor, other := e.SelectionStart, e.SelectionEnd
|
|
if !selActive() {
|
|
anchor, other = e.CursorPosition, e.CursorPosition
|
|
} else if e.SelDragging && e.SelDragWhich == 0 {
|
|
anchor, other = e.SelectionEnd, e.SelectionStart
|
|
}
|
|
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
|
|
if !ok {
|
|
glyphX, lineTop, ok = bytePosToScreenXY(other)
|
|
}
|
|
if !ok {
|
|
// Both ends are outside the shaped window. Hide the menu only when no
|
|
// part of the selection is visible; otherwise keep it at its previous
|
|
// position, clamped inside the window.
|
|
base := glyphBase()
|
|
if e.SelectionEnd <= base || e.SelectionStart >= base+len(e.IMEWindowText) {
|
|
return false
|
|
}
|
|
var winW, winH float64
|
|
if TheState.scale > 0 {
|
|
winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale))
|
|
winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale))
|
|
}
|
|
if winW > 0 {
|
|
if e.MenuRect.X < 8 {
|
|
e.MenuRect.X = 8
|
|
}
|
|
if float64(e.MenuRect.X+e.MenuRect.W) > winW-8 {
|
|
e.MenuRect.X = ui.Dp(winW) - 8 - e.MenuRect.W
|
|
}
|
|
}
|
|
if winH > 0 {
|
|
if e.MenuRect.Y < 8 {
|
|
e.MenuRect.Y = 8
|
|
}
|
|
if float64(e.MenuRect.Y+e.MenuRect.H) > winH-8 {
|
|
e.MenuRect.Y = ui.Dp(winH) - 8 - e.MenuRect.H
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
var winW, winH float64
|
|
if TheState.scale > 0 {
|
|
winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale))
|
|
winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale))
|
|
}
|
|
menuW := menuItemW * ui.Dp(len(e.MenuItems))
|
|
mx := glyphX - float64(menuW)/2
|
|
if mx < 8 {
|
|
mx = 8
|
|
}
|
|
if winW > 0 && mx+float64(menuW) > winW-8 {
|
|
mx = winW - float64(menuW) - 8
|
|
}
|
|
lh := float64(EffectiveLineHeight())
|
|
// Multi-line iff the selection spans more than one logical line.
|
|
// (Testing whether the opposite end sits on a LOWER visual line does not
|
|
// work: a selection ending at a line's trailing newline maps to the NEXT
|
|
// line, so single-line selections would read as multi-line.)
|
|
multiLine := false
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
if li := cb.LineIndex; li != nil && other > anchor {
|
|
multiLine = li.FindLogicalLineForByteOffset(anchor) !=
|
|
li.FindLogicalLineForByteOffset(other-1)
|
|
}
|
|
}
|
|
// Selection bottom: the bottom of the visual line containing the opposite
|
|
// end (the anchor line itself for a single-line selection).
|
|
selBottom := lineTop + lh
|
|
if multiLine {
|
|
if _, ot, ok2 := bytePosToScreenXY(other); ok2 && ot > lineTop+0.5 {
|
|
selBottom = ot + lh
|
|
}
|
|
}
|
|
// Prefer ABOVE the selection's top line (see the doc above).
|
|
my := lineTop - float64(menuH) - 8
|
|
if my < 8 {
|
|
// No room above: place below. A single-line selection needs the menu
|
|
// clear of its handles (see the doc above); a multi-line selection
|
|
// follows the native toolbar (8dp below the selection's bottom edge).
|
|
gap := 8.0
|
|
if !multiLine {
|
|
gap = float64(handleDropDp) + 8
|
|
}
|
|
my = selBottom + gap
|
|
if winH > 0 && my+float64(menuH) > winH-8 {
|
|
my = winH - float64(menuH) - 8 // clamp to window bottom
|
|
}
|
|
}
|
|
if my < 8 {
|
|
my = 8
|
|
}
|
|
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
|
|
return true
|
|
}
|
|
|
|
// showSelectionMenu recomputes the menu items and positions the menu above
|
|
// the line containing the selection end (or caret), falling back to below.
|
|
// Copy and Cut are offered only while a selection is active; Paste always.
|
|
func showSelectionMenu() {
|
|
e := &TheState.Editor
|
|
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
var items []ui.MenuItem
|
|
add := func(icon, label string) {
|
|
items = append(items, ui.MenuItem{
|
|
Icon: icon, Label: label,
|
|
X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH,
|
|
})
|
|
}
|
|
if selActive() {
|
|
add("copy", "Copy")
|
|
add("cut", "Cut")
|
|
}
|
|
add("paste", "Paste")
|
|
e.MenuItems = items
|
|
if positionSelectionMenu(e) {
|
|
e.MenuVisible = true
|
|
}
|
|
}
|
|
|
|
// bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of
|
|
// the glyph at (or just before) the byte, and the top Y of the visual line
|
|
// containing it. ok=false when the layout is empty or the byte is outside
|
|
// the visible window. This is the inverse of textPosFromLocalPoint.
|
|
func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
reg := TheState.EditorRegion
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return 0, 0, false
|
|
}
|
|
base := glyphBase()
|
|
pos := absByte - base
|
|
windowLen := len(TheState.Editor.IMEWindowText)
|
|
if pos < 0 || pos > windowLen {
|
|
return 0, 0, false
|
|
}
|
|
// Same pitch the renderer shaped with (font-scale aware), so the visual
|
|
// line derived from the shaper's baseline matches the drawn geometry.
|
|
lineHeight := float64(EffectiveLineHeight())
|
|
if layout.LineHeight > 0 {
|
|
lineHeight = float64(layout.LineHeight)
|
|
}
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] > pos {
|
|
idx--
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(layout.ByteOffsets) {
|
|
// pos is at/past EOF: anchor on the last glyph.
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
gx := float64(reg.X) + float64(layout.X[idx])
|
|
// layout.Y is the baseline relative to the layout's first line. The
|
|
// shaper places baselines at ascent + lineHeight*lineIndex with
|
|
// 0 < ascent < lineHeight (see drawWrappedText: ascent = FontSize, lineH
|
|
// = FontSize*1.2), so baseline/lineHeight = lineIndex + frac with a frac
|
|
// bounded away from both 0 and 1 — TRUNCATION yields the layout-relative
|
|
// visual line index, robustly (the frac margin is ~0.2*lineHeight, far
|
|
// above float32 noise). Do NOT round to nearest here: with this font's
|
|
// frac ≈ 0.83, +0.5 rounding floors to the line BELOW.
|
|
visualLine := int(float64(layout.Y[idx]) / lineHeight)
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
// The window is drawn shifted up by r' (scrollVisualDecompose). For
|
|
// chunked files the layout is WINDOW-relative (its first line is the
|
|
// window's first line), so visualLine is already relative to the
|
|
// viewport top. For small (string) files the layout covers the WHOLE
|
|
// buffer and the draw shifts by the full scroll offset: subtract the
|
|
// window-start line k so the mapping agrees with the drawn geometry
|
|
// (before this, the menu/caret mapping sat k lines off on scrolled
|
|
// small files).
|
|
k, r := scrollVisualDecompose()
|
|
if TheState.Editor.ChunkedBuffer == nil {
|
|
// May go negative: the anchor is above the viewport. That is the
|
|
// intended off-screen follow-through; callers clamp the resulting
|
|
// position (e.g. the menu pins to the window edge via my < 8).
|
|
visualLine -= k
|
|
}
|
|
lt := float64(reg.Y) - r + float64(visualLine)*lineHeight
|
|
return gx, lt, true
|
|
}
|
|
|
|
// tapInputGuard swallows editor input for a short window after a file opens:
|
|
// the tap that opened the file must not also reposition the caret.
|
|
func tapInputGuard() bool {
|
|
return time.Since(TheState.justOpenedAt) < 300*time.Millisecond
|
|
}
|
|
|
|
// HandleTapAt places the caret at app-local Dp point (x, y). A tap on the
|
|
// visible selection menu is ignored (the menu's own Tap interaction handles
|
|
// it); any other tap dismisses the menu and ends a caret drag.
|
|
func HandleTapAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
e.CaretDrag = false
|
|
hideSelectionMenu()
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
SetCursorFromPoint(localX, localY)
|
|
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
|
|
}
|
|
|
|
// HandleLongPressAt implements Android long-press: on a word it selects the
|
|
// word; on blank space it sets the caret there and shows a single draggable
|
|
// caret handle. The selection menu is shown either way.
|
|
func HandleLongPressAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.TooLarge {
|
|
return
|
|
}
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ws, we, found := wordRangeAt(pos); found {
|
|
SetSelection(ws, we)
|
|
e.CaretDrag = false
|
|
} else {
|
|
ClearSelection()
|
|
e.CursorPosition = pos
|
|
e.CaretDrag = true
|
|
}
|
|
showSelectionMenu()
|
|
}
|
|
|
|
// HandleDoubleTapAt selects the word under the finger (and shows the menu);
|
|
// on blank space it just places the caret.
|
|
func HandleDoubleTapAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.TooLarge {
|
|
return
|
|
}
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ws, we, found := wordRangeAt(pos); found {
|
|
SetSelection(ws, we)
|
|
e.CaretDrag = false
|
|
showSelectionMenu()
|
|
} else {
|
|
SetCursorFromPoint(localX, localY)
|
|
}
|
|
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
|
|
}
|
|
|
|
// HandleSelDragEvt is the registered SelDrag interaction handler. The
|
|
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
|
|
func HandleSelDragEvt(data any) {
|
|
switch ev := data.(type) {
|
|
case ui.SelectionDragEvent:
|
|
selDragMove(ev.Which, ev.X, ev.Y)
|
|
case ui.SelectionDragEnd:
|
|
TheState.Editor.SelDragging = false
|
|
TheState.Editor.SelDragSwapped = false
|
|
TheState.Editor.SelDragRel = 0
|
|
// The caret handle (long press on blank space) is a transient affordance;
|
|
// the selection handles come back from the selection state on the next
|
|
// frame, so only the caret mode is dropped here.
|
|
TheState.Editor.CaretDrag = false
|
|
}
|
|
}
|
|
|
|
// selDragMove applies one finger position of a selection/caret drag.
|
|
func selDragMove(which int, x, y ui.Dp) {
|
|
e := &TheState.Editor
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !e.SelDragging {
|
|
e.SelDragging = true
|
|
e.SelDragWhich = which
|
|
e.SelDragSwapped = false
|
|
e.SelDragPressX = localX
|
|
e.SelDragPressY = localY
|
|
if which == 2 && ok {
|
|
// Body drag: the grab fixes the finger's offset from the selection
|
|
// start; the selection itself moves on later events.
|
|
e.SelDragRel = pos - e.SelectionStart
|
|
if e.SelDragRel < 0 {
|
|
e.SelDragRel = 0
|
|
}
|
|
return
|
|
}
|
|
// Start/end/caret handles: the grab fixes the anchor's text position;
|
|
// later events move it by the finger's displacement from the grab.
|
|
var anchor int
|
|
switch which {
|
|
case 0:
|
|
anchor = e.SelectionStart
|
|
case 1:
|
|
anchor = e.SelectionEnd
|
|
default: // 3: caret
|
|
anchor = e.CursorPosition
|
|
}
|
|
if ax, ay, ok2 := textPointLocal(anchor); ok2 {
|
|
e.SelDragAnchorX = ax
|
|
e.SelDragAnchorY = ay
|
|
}
|
|
return
|
|
}
|
|
switch e.SelDragWhich {
|
|
case 0, 1: // start / end handle
|
|
// Move the anchor by the finger's displacement from the grab (1:1
|
|
// tracking: the teardrop stays under the finger, the selection
|
|
// resizes continuously, lines are crossed only when the finger's
|
|
// mapped point crosses them).
|
|
pos, ok = handleFollowPos(e, localX, localY)
|
|
if !ok {
|
|
return // finger outside the laid-out window: keep last position
|
|
}
|
|
// Crossing the opposite handle flips the selection (native behaviour)
|
|
// instead of clamping to zero length and clearing it; while flipped the
|
|
// dragged handle controls the end it crossed.
|
|
if e.SelDragWhich == 0 {
|
|
if !e.SelDragSwapped {
|
|
if pos > e.SelectionEnd {
|
|
e.SelDragSwapped = true
|
|
SetSelection(e.SelectionEnd, pos)
|
|
} else if pos < e.SelectionEnd {
|
|
SetSelection(pos, e.SelectionEnd)
|
|
}
|
|
} else {
|
|
if pos < e.SelectionStart {
|
|
e.SelDragSwapped = false
|
|
SetSelection(pos, e.SelectionStart)
|
|
} else if pos > e.SelectionStart {
|
|
SetSelection(e.SelectionStart, pos)
|
|
}
|
|
}
|
|
} else {
|
|
if !e.SelDragSwapped {
|
|
if pos < e.SelectionStart {
|
|
e.SelDragSwapped = true
|
|
SetSelection(pos, e.SelectionStart)
|
|
} else if pos > e.SelectionStart {
|
|
SetSelection(e.SelectionStart, pos)
|
|
}
|
|
} else {
|
|
if pos > e.SelectionEnd {
|
|
e.SelDragSwapped = false
|
|
SetSelection(e.SelectionEnd, pos)
|
|
} else if pos < e.SelectionEnd {
|
|
SetSelection(e.SelectionEnd, pos)
|
|
}
|
|
}
|
|
}
|
|
case 2: // body: move the whole selection, preserving length
|
|
if !ok {
|
|
return
|
|
}
|
|
selLen := e.SelectionEnd - e.SelectionStart
|
|
ns := pos - e.SelDragRel
|
|
fl := fileLenBytes()
|
|
if ns < 0 {
|
|
ns = 0
|
|
}
|
|
if ns+selLen > fl {
|
|
ns = fl - selLen
|
|
}
|
|
SetSelection(ns, ns+selLen)
|
|
case 3: // caret drag handle
|
|
if p, ok2 := handleFollowPos(e, localX, localY); ok2 {
|
|
e.CursorPosition = p
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleFollowPos maps the dragged handle's target point — the anchor's grab
|
|
// position plus the finger's displacement from the grab — to a byte offset.
|
|
func handleFollowPos(e *EditorState, localX, localY float64) (int, bool) {
|
|
tx := e.SelDragAnchorX + (localX - e.SelDragPressX)
|
|
ty := e.SelDragAnchorY + (localY - e.SelDragPressY)
|
|
return textPosFromLocalPoint(tx, ty)
|
|
}
|
|
|
|
// textPointLocal is the inverse of textPosFromLocalPoint: the text-local
|
|
// (x, y) point that maps back to byteOff. x is the insertion point's
|
|
// x (the glyph's left edge, or the previous glyph's right edge), and y is
|
|
// the MIDDLE of the byte's visual line band so that textPosFromLocalPoint's
|
|
// int(y/lineHeight) line resolution lands on the byte's own line.
|
|
func textPointLocal(byteOff int) (x, y float64, ok bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 {
|
|
return 0, 0, false
|
|
}
|
|
lh := float64(EffectiveLineHeight())
|
|
if lh <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
base := glyphBase()
|
|
winByte := byteOff - base
|
|
if winByte < 0 {
|
|
winByte = 0
|
|
}
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= winByte
|
|
})
|
|
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] == winByte {
|
|
x = float64(layout.X[idx])
|
|
} else if idx > 0 {
|
|
// The byte sits between glyphs (mid-multibyte) or past the last
|
|
// glyph (EOF): the previous glyph's right edge.
|
|
x = float64(layout.X[idx-1] + layout.Advance[idx-1])
|
|
}
|
|
line, ok2 := visualLineOfByte(winByte)
|
|
if !ok2 {
|
|
return 0, 0, false
|
|
}
|
|
y = (float64(line) + 0.5) * lh
|
|
return x, y, true
|
|
}
|
|
|
|
// visualLineOfByte returns the visual line (0-based within the shaped
|
|
// window) that holds the insertion point at the given window-relative byte
|
|
// offset: the last visual line whose start byte is at or before the offset.
|
|
// A line's terminating newline belongs to that line, and an empty line's
|
|
// lone insertion point sits on the empty line (the first-glyph-at-or-past
|
|
// rule would put both on the next line, which is wrong for empty lines).
|
|
func visualLineOfByte(winByte int) (int, bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 {
|
|
return 0, false
|
|
}
|
|
// The insertion point at winByte is drawn on the last visual line whose
|
|
// first byte is at or before it: a line's terminating newline belongs to
|
|
// that line, and an empty line's lone insertion point sits on the empty
|
|
// line. (Looking at the first glyph at/past the byte instead is wrong
|
|
// around empty lines: that glyph is on the NEXT line.) This must agree
|
|
// with textPosOnLineAtX's line starts so a dragged handle's grab anchor
|
|
// round-trips.
|
|
if starts := layout.VisualLineStarts; len(starts) > 0 {
|
|
if winByte < 0 {
|
|
winByte = 0
|
|
}
|
|
idx := sort.Search(len(starts), func(i int) bool {
|
|
return starts[i] > winByte
|
|
})
|
|
if idx > 0 {
|
|
idx--
|
|
}
|
|
return idx, true
|
|
}
|
|
lineHeight := float64(EffectiveLineHeight())
|
|
minY := 1e9
|
|
for _, yVal := range layout.Y {
|
|
if float64(yVal) < minY {
|
|
minY = float64(yVal)
|
|
}
|
|
}
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= winByte
|
|
})
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
lineIdx := int((float64(layout.Y[idx])-minY)/lineHeight + 0.5) // round to nearest line
|
|
if lineIdx < 0 {
|
|
lineIdx = 0
|
|
}
|
|
return lineIdx, true
|
|
}
|
|
|
|
// HandleMenuTap hit-tests a tap on the menu panel and runs the tapped item.
|
|
func HandleMenuTap(x, y ui.Dp) {
|
|
e := &TheState.Editor
|
|
if !e.MenuVisible || !pointInMenu(x, y) {
|
|
return
|
|
}
|
|
idx := int((x - e.MenuRect.X) / menuItemW)
|
|
if idx < 0 || idx >= len(e.MenuItems) {
|
|
return
|
|
}
|
|
switch e.MenuItems[idx].Icon {
|
|
case "copy":
|
|
handleMenuCopy()
|
|
case "cut":
|
|
handleMenuCut()
|
|
case "paste":
|
|
handleMenuPaste()
|
|
}
|
|
}
|
|
|
|
// selectedText returns the selected bytes.
|
|
func selectedText() (string, bool) {
|
|
e := &TheState.Editor
|
|
if !selActive() {
|
|
return "", false
|
|
}
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
return cb.Content(e.SelectionStart, e.SelectionEnd), true
|
|
}
|
|
return e.Buffer[e.SelectionStart:e.SelectionEnd], true
|
|
}
|
|
|
|
func handleMenuCopy() {
|
|
text, ok := selectedText()
|
|
if !ok {
|
|
return
|
|
}
|
|
TheState.clipboardSetChan <- text
|
|
// The selection (and its highlight) stays; only the menu goes away,
|
|
// matching Android behaviour.
|
|
hideSelectionMenu()
|
|
}
|
|
|
|
func handleMenuCut() {
|
|
e := &TheState.Editor
|
|
text, ok := selectedText()
|
|
if !ok {
|
|
return
|
|
}
|
|
TheState.clipboardSetChan <- text
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
markDirty()
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
}
|
|
|
|
func handleMenuPaste() {
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
TheState.pasteReqChan <- struct{}{}
|
|
// The read is asynchronous (DataEvent on a later frame), but the menu
|
|
// has served its purpose and closes immediately, as on Android.
|
|
hideSelectionMenu()
|
|
}
|
|
|
|
// HandlePaste inserts the system clipboard text (replacing a live selection,
|
|
// per the selection-aware edit rule). Runs on the logic goroutine.
|
|
func HandlePaste(text string) {
|
|
if text == "" {
|
|
return
|
|
}
|
|
HandleInsert(text)
|
|
}
|
|
|
|
// HandleHome moves the cursor to the start of the current visual line.
|
|
func HandleHome() {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentY := layout.Y[idx]
|
|
// Find first glyph on currentY.
|
|
targetIdx := idx
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] == currentY {
|
|
targetIdx = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// HandleEnd moves the cursor to the end of the current visual line.
|
|
func HandleEnd() {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentY := layout.Y[idx]
|
|
// Find last glyph on currentY.
|
|
targetIdx := idx
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == currentY {
|
|
targetIdx = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Position after the last character of the line.
|
|
// If it's a newline, it's the newline itself.
|
|
// start is window-relative; convert to an absolute file offset.
|
|
start := base + layout.ByteOffsets[targetIdx]
|
|
buf := TheState.Editor.ChunkedBuffer
|
|
var fileContent string
|
|
if buf != nil {
|
|
content, err := buf.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full content: %v", err)
|
|
return
|
|
}
|
|
fileContent = content
|
|
} else {
|
|
fileContent = TheState.Editor.Buffer
|
|
}
|
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
|
if r == '\n' {
|
|
TheState.Editor.CursorPosition = start
|
|
} else {
|
|
TheState.Editor.CursorPosition = start + size
|
|
}
|
|
}
|
|
|
|
// HandlePageUpDown scrolls and moves the cursor.
|
|
func HandlePageUpDown(up bool) {
|
|
// For now, simple scrolling. Cursor movement could be added later.
|
|
pageSize := TheState.MaxScroll / 4 // Or some fraction
|
|
if pageSize < EffectiveLineHeight() {
|
|
pageSize = EffectiveLineHeight()
|
|
}
|
|
|
|
if up {
|
|
TheState.ScrollOffset -= pageSize
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
} else {
|
|
TheState.ScrollOffset += pageSize
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
}
|
|
}
|
|
func HandleVerticalCursorMove(up bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
// If idx == len, we are at the end. Use the last glyph.
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentX := layout.X[idx]
|
|
currentY := layout.Y[idx]
|
|
|
|
var targetIdx int = idx
|
|
|
|
if up {
|
|
// Scan backwards to find the previous line's Y.
|
|
prevY := currentY
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] < prevY {
|
|
prevY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if prevY == currentY {
|
|
// Already at the top line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in prevY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == prevY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Scan forwards to find the next line's Y.
|
|
nextY := currentY
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] > nextY {
|
|
nextY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if nextY == currentY {
|
|
// Already at the bottom line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in nextY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == nextY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// utf8BackspaceWidth returns the byte width of the UTF-8 rune ending at the
|
|
// end of seg (the rune immediately before the cursor). seg must contain the
|
|
// rune's full bytes (4 or more preceding bytes suffice).
|
|
func utf8BackspaceWidth(seg string) int {
|
|
// The rune ends at the end of seg. Scan back over continuation bytes
|
|
// (0x80-0xBF) until the lead byte; the width is the span covered.
|
|
i := len(seg) - 1
|
|
for i > 0 && seg[i]&0xC0 == 0x80 {
|
|
i--
|
|
}
|
|
return len(seg) - i
|
|
}
|
|
|
|
// utf8AdvanceWidth returns the byte width of the UTF-8 rune starting at the
|
|
// beginning of seg (4 or more following bytes suffice). Returns 0 if seg is
|
|
// empty (cursor at EOF).
|
|
func utf8AdvanceWidth(seg string) int {
|
|
if len(seg) == 0 {
|
|
return 0
|
|
}
|
|
w := 1
|
|
for i := 1; i < len(seg) && seg[i]&0xC0 == 0x80; i++ {
|
|
w++
|
|
}
|
|
return w
|
|
}
|
|
|
|
// HandleDelete removes the character (full UTF-8 rune) after the cursor.
|
|
// With a live selection it deletes the whole selection.
|
|
func HandleDelete() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
seg := buf.Content(pos, pos+4)
|
|
if w := utf8AdvanceWidth(seg); w > 0 {
|
|
buf.Delete(pos, w)
|
|
buf.UpdateLineIndexAfterDelete(pos, pos+w)
|
|
e.findEdit(pos, pos+w, 0)
|
|
markDirty()
|
|
}
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
if pos >= len(str) {
|
|
return
|
|
}
|
|
end := len(str)
|
|
if pos+4 < end {
|
|
end = pos + 4
|
|
}
|
|
w := utf8AdvanceWidth(str[pos:end])
|
|
TheState.Editor.Buffer = str[:pos] + str[pos+w:]
|
|
e.findEdit(pos, pos+w, 0)
|
|
markDirty()
|
|
}
|
|
|
|
// HandleInsert inserts a string at the current cursor position. With a live
|
|
// selection it replaces the selection instead.
|
|
func HandleInsert(s string) {
|
|
// EditorState is embedded by value in State: take the address, never a
|
|
// copy, or the writes below are lost.
|
|
e := &TheState.Editor
|
|
pos := e.CursorPosition
|
|
if selActive() {
|
|
pos = e.SelectionStart
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
ClearSelection()
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
buf.Insert(pos, s)
|
|
buf.UpdateLineIndexAfterInsert(pos, s)
|
|
} else {
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := e.Buffer
|
|
e.Buffer = str[:pos] + s + str[pos:]
|
|
}
|
|
e.findEdit(pos, pos, len(s))
|
|
e.CursorPosition = pos + len(s)
|
|
markDirty()
|
|
}
|
|
|
|
// HandleBackspace removes the character before the cursor. With a live
|
|
// selection it deletes the whole selection instead.
|
|
func HandleBackspace() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
if pos == 0 {
|
|
return
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(buf.Content(segStart, pos))
|
|
buf.Delete(pos-w, w)
|
|
buf.UpdateLineIndexAfterDelete(pos-w, pos)
|
|
TheState.Editor.CursorPosition = pos - w
|
|
e.findEdit(pos-w, pos, 0)
|
|
markDirty()
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(str[segStart:pos])
|
|
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
|
|
TheState.Editor.CursorPosition = pos - w
|
|
e.findEdit(pos-w, pos, 0)
|
|
markDirty()
|
|
}
|
|
|
|
// runeIndexToByteStr returns the byte offset of the n-th rune (0-indexed) in
|
|
// s. A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte lead byte
|
|
// (>=0xC0); 0x80-0xBF are continuation bytes. If n is past the end, returns
|
|
// len(s).
|
|
func runeIndexToByteStr(s string, n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
runes := 0
|
|
for i := 0; i < len(s); i++ {
|
|
b := s[i]
|
|
if b < 0x80 || b >= 0xC0 {
|
|
if runes == n {
|
|
return i
|
|
}
|
|
runes++
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
|
|
// with text and places the cursor at the end of the inserted text.
|
|
//
|
|
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
|
|
// or autocorrect commit replaces the selected region instead of blindly
|
|
// inserting at the cursor, so the old text is removed (no duplication). It
|
|
// also covers plain inserts (start==end) and range deletes (text == "").
|
|
//
|
|
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
|
|
// is byte-based, so they are converted to byte offsets first. Must be called
|
|
// on the logic goroutine (owner).
|
|
func HandleReplaceRange(startRune, endRune int, text string) {
|
|
// A too-large file is not editable: ignore IME commits.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
if startRune > endRune {
|
|
startRune, endRune = endRune, startRune
|
|
}
|
|
// The IME snippet is the visible window (IMEWindowText), so startRune/
|
|
// endRune are relative to that window. Resolve them to window-byte
|
|
// offsets, then add the window's absolute start to address the buffer.
|
|
// For small (string) files the window is the whole buffer (start 0), so
|
|
// this reduces to absolute addressing. If IMEWindowText is empty (tests
|
|
// that never run layout), fall back to the whole buffer as the window.
|
|
windowStart := TheState.Editor.IMEWindowStartByte
|
|
windowText := TheState.Editor.IMEWindowText
|
|
if windowText == "" {
|
|
windowStart = 0
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
windowText, _ = cb.FullContent()
|
|
} else {
|
|
windowText = TheState.Editor.Buffer
|
|
}
|
|
}
|
|
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
|
|
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
|
|
// A live selection is always replaced by the commit: union the IME range
|
|
// with the selection so the outcome is deterministic no matter what the
|
|
// IME reports (some IMEs send the full selection range, others send an
|
|
// empty range at the caret expecting the app to consume its reported
|
|
// selection).
|
|
if selActive() {
|
|
if absStart > TheState.Editor.SelectionStart {
|
|
absStart = TheState.Editor.SelectionStart
|
|
}
|
|
if absEnd < TheState.Editor.SelectionEnd {
|
|
absEnd = TheState.Editor.SelectionEnd
|
|
}
|
|
}
|
|
if imeDebugLog {
|
|
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
|
|
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
|
|
}
|
|
|
|
var newCursor int
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
if absEnd > absStart {
|
|
buf.Delete(absStart, absEnd-absStart)
|
|
}
|
|
buf.Insert(absStart, text)
|
|
if absEnd > absStart {
|
|
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
|
|
}
|
|
buf.UpdateLineIndexAfterInsert(absStart, text)
|
|
newCursor = absStart + len(text)
|
|
} else {
|
|
s := TheState.Editor.Buffer
|
|
if absEnd > absStart {
|
|
s = s[:absStart] + s[absEnd:]
|
|
}
|
|
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
|
|
newCursor = absStart + len(text)
|
|
}
|
|
TheState.Editor.findEdit(absStart, absEnd, len(text))
|
|
TheState.Editor.CursorPosition = newCursor
|
|
// A commit consumed any selection it overlapped (see the union above).
|
|
ClearSelection()
|
|
if imeDebugLog {
|
|
dbgBuf := currentEditorText()
|
|
if len(dbgBuf) > 40 {
|
|
dbgBuf = dbgBuf[:40]
|
|
}
|
|
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
|
|
}
|
|
markDirty()
|
|
}
|
|
|
|
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
|
|
// use; enable when debugging IME commit/cursor sync on device.
|
|
const imeDebugLog = false
|
|
|
|
// currentEditorText returns the current editor buffer contents (full for
|
|
// chunked files). Used only for debug logging.
|
|
func currentEditorText() string {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
s, _ := cb.FullContent()
|
|
return s
|
|
}
|
|
return TheState.Editor.Buffer
|
|
}
|
|
|
|
func markDirty() {
|
|
// Every content edit funnels through here, so EditSeq is the universal
|
|
// "content changed" token (the WrapIndex layout-correlation gate uses it).
|
|
TheState.Editor.EditSeq++
|
|
// TheLogic is nil in pure unit tests (no logic goroutine). Editing the
|
|
// buffer is still valid there; only the autosave side-effect is skipped.
|
|
if TheLogic != nil {
|
|
TheLogic.markDirty()
|
|
}
|
|
}
|
|
|
|
// EditorLayout computes the element tree for the editor page.
|
|
// FindBarHeight is the find bar's height; buildFindBar and EditorLayout
|
|
// (which shrinks the text area to make room) both use it.
|
|
const FindBarHeight = ui.Dp(40)
|
|
|
|
// buildFindBar lays out the in-file search bar: [input][counter][prev][next]
|
|
// [clear-X], full screen width, directly below the margin-free top bar (the
|
|
// bars merge, so this one does too; inner content keeps the margin). The
|
|
// input is a MAIN-owned GioEditor registered as "find_bar" (the browser
|
|
// "search_bar" precedent, architecture.md §1); the counter and buttons are
|
|
// logic-owned elements whose taps run the handlers in search.go.
|
|
func buildFindBar(screenWidth ui.Dp) ui.Element {
|
|
margin := ui.Dp(10)
|
|
gap := ui.Dp(8)
|
|
h := FindBarHeight
|
|
topY := ui.Dp(32) // top bar height (see EditorLayout)
|
|
|
|
// Right-hand button column: prev, next, clear (X).
|
|
iconW := ui.IconSize
|
|
closeX := screenWidth - margin - iconW
|
|
nextX := closeX - gap - iconW
|
|
prevX := nextX - gap - iconW
|
|
counterW := ui.Dp(96)
|
|
inputX := margin
|
|
inputW := prevX - gap - counterW - margin
|
|
|
|
f := TheState.Editor.Find
|
|
counter := "0"
|
|
switch {
|
|
case f.Scanning:
|
|
counter = "…"
|
|
case f.Query != "" && len(f.Matches) == 0:
|
|
counter = "No matches"
|
|
case f.Cur >= 0:
|
|
counter = fmt.Sprintf("%d / %d", f.Cur+1, len(f.Matches))
|
|
}
|
|
|
|
return ui.NewContainer(
|
|
ui.Region{X: 0, Y: topY, W: screenWidth, H: h},
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewGioEditor("find_bar", ui.Region{X: inputX, Y: ui.Dp(6), W: inputW, H: ui.Dp(28)}),
|
|
ui.NewLabel(counter, 12, ui.Region{X: inputX + inputW + gap, Y: ui.Dp(8), W: counterW, H: ui.Dp(20)}, ui.AlignEnd, "", nil),
|
|
ui.NewIcon("chevron_up", ui.Region{X: prevX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindPrev}}),
|
|
ui.NewIcon("chevron_down", ui.Region{X: nextX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindNext}}),
|
|
ui.NewIcon("close", ui.Region{X: closeX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindClear}}),
|
|
},
|
|
)
|
|
}
|
|
|
|
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|
margin := ui.Dp(10)
|
|
|
|
// --- Top bar: one row, back icon + filename. Cut/copy/paste live in the
|
|
// floating selection menu (the native Android pattern); the dead icon row
|
|
// is gone, saving 20dp of editor height. The bar spans the full screen
|
|
// width with no outer margin (it merges with the system UI); the inner
|
|
// content keeps the margin so it aligns with the text area below. ---
|
|
statusBarRegion := ui.Region{
|
|
X: 0, Y: 0,
|
|
W: screenWidth,
|
|
H: ui.Dp(32),
|
|
}
|
|
statusBarW := statusBarRegion.W
|
|
filename := TheState.Editor.Filename
|
|
if filename == "" {
|
|
filename = "untitled.txt"
|
|
}
|
|
statusBar := ui.NewContainer(
|
|
statusBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewIcon("back", ui.Region{X: margin, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
|
|
ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32) + margin, Y: ui.Dp(6), W: statusBarW - ui.Dp(32) - margin - ui.IconSize - ui.Dp(8), H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
ui.NewIcon("search", ui.Region{X: screenWidth - margin - ui.IconSize, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleFind}}),
|
|
},
|
|
)
|
|
|
|
// --- Bottom bar: full screen width, flush with the screen bottom (no
|
|
// outer margin, same as the top bar). Inner labels keep the margin. ---
|
|
bottomBarHeight := ui.BottomBarHeight
|
|
bottomBarY := screenHeight - bottomBarHeight
|
|
bottomBarRegion := ui.Region{
|
|
X: 0, Y: bottomBarY,
|
|
W: screenWidth,
|
|
H: bottomBarHeight,
|
|
}
|
|
bottomBarW := bottomBarRegion.W
|
|
wrapText := "Wrap: Off"
|
|
if wordWrap {
|
|
wrapText = "Wrap: On"
|
|
}
|
|
statusText := "Saved"
|
|
if TheState.Editor.IsSaving() {
|
|
statusText = "Saving..."
|
|
} else if TheState.Editor.WriteFailed() {
|
|
statusText = "Error"
|
|
} else if TheState.Editor.IsDirty() {
|
|
statusText = "Modified"
|
|
}
|
|
|
|
// Compute cursor position and file size for the bottom bar.
|
|
cursorPos := TheState.Editor.CursorPosition
|
|
var fileSize int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
fileSize = int(cb.FileLen())
|
|
} else {
|
|
fileSize = len(TheState.Editor.Buffer)
|
|
}
|
|
cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize)
|
|
|
|
bottomBar := ui.NewContainer(
|
|
bottomBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewLabel(statusText, 12, ui.Region{X: margin, Y: ui.Dp(2), W: bottomBarW - margin, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
|
|
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW - margin, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
|
|
{Gesture: ui.Tap, Handler: ToggleWordWrap},
|
|
}),
|
|
},
|
|
)
|
|
|
|
// --- Find bar (in-file search): full width, directly below the top bar,
|
|
// while open (see search.go / buildFindBar). ---
|
|
var findBar ui.Element
|
|
if TheState.Editor.Find.Visible {
|
|
findBar = buildFindBar(screenWidth)
|
|
}
|
|
|
|
// --- Editor text area ---
|
|
// While the find bar is open it sits in [32, 32+FindBarHeight); shrink the
|
|
// text area from the top so the first visible line is not covered.
|
|
editorY := statusBarRegion.Y + statusBarRegion.H
|
|
if TheState.Editor.Find.Visible {
|
|
editorY += FindBarHeight
|
|
}
|
|
editorH := bottomBarRegion.Y - editorY
|
|
editorRegion := ui.Region{
|
|
X: margin, Y: editorY,
|
|
W: screenWidth - margin*2,
|
|
H: editorH,
|
|
}
|
|
// Stored for the input handlers (tap / long press / drags), which convert
|
|
// app-local Dp points to text-local coordinates through it.
|
|
TheState.EditorRegion = editorRegion
|
|
// WindowStartLine is the logical line the visible window starts at; it is
|
|
// shipped with the shaped layout (Frame) so the layout-correlation pass in
|
|
// the logic goroutine knows which lines the layout describes. -1 outside
|
|
// the editor window (browser page, too-large notice).
|
|
TheState.WindowStartLine = -1
|
|
// Compute max scroll offset from the last line baseline reported by the renderer.
|
|
// lastLineY is the shaper's Y value for the last line's baseline.
|
|
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
|
|
var maxScroll ui.Dp
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if li := cb.LineIndex; li != nil {
|
|
// Document height is measured in VISUAL lines: a wrapped logical
|
|
// line occupies several of them, so the max scroll must use the
|
|
// WrapIndex total, not the logical line count. Before shaping,
|
|
// every line estimates to one visual line, so MaxScroll starts at
|
|
// the no-wrap value and grows as shaped counts arrive — it only
|
|
// ever grows during a warm-up, never jumps under the viewport.
|
|
total := int64(li.LineCount())
|
|
if w := cb.WrapIndex; w != nil {
|
|
total = int64(w.TotalVisuals())
|
|
}
|
|
lineHeight := EffectiveLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
maxScroll = ui.Dp(float64(total)*float64(lineHeight)) - editorRegion.H + lineHeight/2
|
|
} else {
|
|
// If index is not yet built, allow scrolling beyond estimate.
|
|
// Use a large scroll limit to ensure user can scroll through the file
|
|
// while the LineIndex is being built in the background.
|
|
maxScroll = ui.Dp(1000000) * EffectiveLineHeight()
|
|
}
|
|
} else {
|
|
// Fallback for full buffer
|
|
maxScroll = TheState.LastLineY - editorRegion.H + EffectiveLineHeight()/2
|
|
}
|
|
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
TheState.MaxScroll = maxScroll
|
|
// Keep the viewport within [0, MaxScroll] even when MaxScroll just shrank
|
|
// (the line index finished building, or a shorter file was opened). Without
|
|
// this, a ScrollOffset set while MaxScroll was the large pre-index estimate
|
|
// would stay past the (now smaller) content and render a blank viewport.
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > maxScroll {
|
|
TheState.ScrollOffset = maxScroll
|
|
}
|
|
|
|
// Compute visible content for virtual scrolling.
|
|
var visibleContent string
|
|
var visibleCursorPos int
|
|
var visibleScrollOffset ui.Dp
|
|
var start, end int
|
|
|
|
cb := TheState.Editor.ChunkedBuffer
|
|
if TheState.Editor.TooLarge {
|
|
// The file exceeds the edit limit: show a notice instead of content and
|
|
// do not edit (the browser can still list the file).
|
|
visibleContent = fmt.Sprintf(
|
|
"Too large to edit\n\n%s is %s, above the %s limit.\nIt is still listed in the browser.",
|
|
TheState.Editor.Filename, formatSize(TheState.Editor.TooLargeSize), formatSize(MaxEditableFileSize))
|
|
visibleCursorPos = 0
|
|
visibleScrollOffset = 0
|
|
} else if cb != nil {
|
|
viewportHeight := editorRegion.H
|
|
lineHeight := EffectiveLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
// Assign (not :=) into the outer start/end: they are read below the
|
|
// block to derive IMEWindowStartByte and the window-relative
|
|
// selection. A `:=` here would shadow them (winLine is new) and the
|
|
// outer pair would stay 0 — every scrolled frame would then map the
|
|
// selection against byte 0 (the selection "jumps" with the scroll) and
|
|
// IME commits would land at the wrong buffer position.
|
|
var winLine int
|
|
start, end, winLine = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
|
// Ship with the frame: the shaped layout's wrap counts belong to THIS
|
|
// window's lines (a scroll may move the window before the layout
|
|
// arrives, but an edit invalidates it — see EditorState.EditSeq).
|
|
TheState.WindowStartLine = winLine
|
|
|
|
// Proactively load chunks needed for the current viewport
|
|
startChunk := start / cb.ChunkSize()
|
|
endChunk := (end - 1) / cb.ChunkSize()
|
|
for i := startChunk; i <= endChunk; i++ {
|
|
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
|
|
cb.LoadChunkAsync(i)
|
|
}
|
|
}
|
|
|
|
// Extract visible content from chunked buffer. Read the full visible
|
|
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
|
|
// window could leave the lower rows blank).
|
|
visibleContent = cb.Content(start, end)
|
|
|
|
// Adjust cursor position to be relative to visibleContent. It may be
|
|
// NEGATIVE (the cursor's line scrolled above the window) or exceed
|
|
// len(visibleContent) (scrolled below): the renderer treats an
|
|
// out-of-window cursor as off-screen and draws no caret, like a native
|
|
// editor. Clamping to the window edge here made the caret appear on
|
|
// the top/bottom visible line whenever the user scrolled past it.
|
|
visibleCursorPos = TheState.Editor.CursorPosition - start
|
|
|
|
// Adjust scroll offset to be relative to visibleContent origin
|
|
// Sub-line shift for the renderer: the SAME visual-line decomposition
|
|
// the window start used (VisibleByteRange above), so the drawn
|
|
// geometry and the window's content lines agree for every scroll
|
|
// offset and wrap state. scrollVisualDecompose re-derives it from the
|
|
// same inputs; the two agree by construction (see its doc).
|
|
_, subLine := scrollVisualDecompose()
|
|
visibleScrollOffset = ui.Dp(subLine)
|
|
|
|
// Map scroll offset to a chunk index
|
|
// Estimate line-to-byte conversion if LineIndex is missing
|
|
var scrollByteOffset int
|
|
if li := cb.LineIndex; li != nil {
|
|
// Precise: same decomposition as the window start above.
|
|
prefetchLine := winLine
|
|
if prefetchLine < li.LineCount() {
|
|
scrollByteOffset = li.ByteOffset(prefetchLine)
|
|
} else {
|
|
scrollByteOffset = int(cb.FileLen())
|
|
}
|
|
} else {
|
|
// Estimate
|
|
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EffectiveLineHeight())
|
|
scrollByteOffset = prefetchLine * 50
|
|
}
|
|
|
|
scrollChunk := scrollByteOffset / cb.ChunkSize()
|
|
// Use a smaller radius to prevent overloading the worker pool.
|
|
// Content() will load chunks immediately needed by the viewport.
|
|
cb.Prefetch(scrollChunk, 1)
|
|
} else {
|
|
// Fallback: no chunked buffer, use full buffer (small files)
|
|
visibleContent = TheState.Editor.Buffer
|
|
visibleCursorPos = TheState.Editor.CursorPosition
|
|
visibleScrollOffset = TheState.ScrollOffset
|
|
}
|
|
|
|
// Record the shaped visible range for the profiler probe (confirms the
|
|
// virtual-scroll window stays viewport-bounded, not the whole file).
|
|
TheState.VisibleStart = start
|
|
TheState.VisibleEnd = end
|
|
|
|
// Record the visible window for IME: the snippet is this window, so an
|
|
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
|
// to address the buffer. start is 0 for small (string) files, so the
|
|
// window is the whole buffer there.
|
|
TheState.Editor.IMEWindowStartByte = start
|
|
TheState.Editor.IMEWindowText = visibleContent
|
|
// Window-relative selection for the TextField (byte offsets into
|
|
// visibleContent); -1 means nothing visible is selected. The IME push and
|
|
// the in-app highlight consume this; the logic side keeps absolute
|
|
// offsets in EditorState.
|
|
windowSelStart, windowSelEnd := -1, -1
|
|
if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss {
|
|
ws, we := ss-start, TheState.Editor.SelectionEnd-start
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
if we > len(visibleContent) {
|
|
we = len(visibleContent)
|
|
}
|
|
if ws < we {
|
|
windowSelStart, windowSelEnd = ws, we
|
|
}
|
|
}
|
|
|
|
// Window-relative search matches for the in-text highlight (byte ranges
|
|
// into visibleContent). Matches are sorted, so binary-search the first
|
|
// that can intersect the window and walk forward. The window is
|
|
// [start, start+len(visibleContent)).
|
|
var windowMatches [][2]int
|
|
windowCur := -1
|
|
if f := TheState.Editor.Find; f.Visible && len(f.Matches) > 0 && !TheState.Editor.TooLarge {
|
|
winEnd := start + len(visibleContent)
|
|
n := len(f.Matches)
|
|
i := sort.Search(n, func(i int) bool { return f.Matches[i][0] >= start })
|
|
if i > 0 {
|
|
i-- // a match starting before the window may extend into it
|
|
}
|
|
for ; i < n && f.Matches[i][0] < winEnd; i++ {
|
|
ws, we := f.Matches[i][0]-start, f.Matches[i][1]-start
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
if we > len(visibleContent) {
|
|
we = len(visibleContent)
|
|
}
|
|
if ws < we {
|
|
if i == f.Cur {
|
|
windowCur = len(windowMatches)
|
|
}
|
|
windowMatches = append(windowMatches, [2]int{ws, we})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the TextField back in a way that passes the test.
|
|
editorElem := ui.NewTextField(
|
|
"editor_text",
|
|
visibleContent,
|
|
editorRegion,
|
|
wordWrap,
|
|
editorRegion.W,
|
|
visibleScrollOffset,
|
|
visibleCursorPos,
|
|
windowSelStart,
|
|
windowSelEnd,
|
|
[]ui.Interaction{
|
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
|
// The Tap interaction also carries the long-press and double-tap
|
|
// events the renderer derives from the same gesture.Click (they share
|
|
// the editor's click region). The renderer converts all of them to
|
|
// text-local coordinates through EditorRegion + tapLocalY.
|
|
{Gesture: ui.Tap, Handler: func(data any) {
|
|
// The just-opened guard (swallowing the tap that opened the file)
|
|
// lives inside the Handle* functions.
|
|
switch pt := data.(type) {
|
|
case ui.Point:
|
|
HandleTapAt(pt.X, pt.Y)
|
|
case ui.DoubleTapPoint:
|
|
HandleDoubleTapAt(pt.X, pt.Y)
|
|
case ui.LongPressPoint:
|
|
HandleLongPressAt(pt.X, pt.Y)
|
|
}
|
|
}},
|
|
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
|
},
|
|
)
|
|
// While the find bar is open, key focus belongs to the main-owned
|
|
// "find_bar" widget.Editor: the editor stops its per-frame IME sync, and
|
|
// regaining "editor_text" focus re-issues key.FocusCmd on close (see
|
|
// TextField.Draw's focus dedup).
|
|
if TheState.Editor.Find.Visible {
|
|
TheState.FocusedElementID = "find_bar"
|
|
} else if TheState.FocusedElementID == "find_bar" {
|
|
TheState.FocusedElementID = "editor_text"
|
|
}
|
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
|
// for Gio to deliver key events to this element. ShowIMESeq carries the
|
|
// keyboard-raise pulse (see TextField.ShowIMESeq).
|
|
editorElem.ShowIMESeq = TheState.Editor.ShowIMESeq
|
|
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
|
// Caret handle visibility (long press on blank space).
|
|
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
|
editorElem.MatchRanges = windowMatches
|
|
editorElem.CurrentMatch = windowCur
|
|
|
|
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
|
if findBar != nil {
|
|
elems = append(elems, findBar)
|
|
}
|
|
// The selection menu is added last so it draws on top of the editor.
|
|
// Re-anchor it to the selection's live screen position every frame so it
|
|
// follows the text while scrolling (it used to stay where it was first
|
|
// shown, detaching from the selection).
|
|
if TheState.Editor.MenuVisible {
|
|
if !positionSelectionMenu(&TheState.Editor) {
|
|
hideSelectionMenu()
|
|
}
|
|
}
|
|
if TheState.Editor.MenuVisible {
|
|
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
|
|
if pt, ok := data.(ui.Point); ok {
|
|
HandleMenuTap(pt.X, pt.Y)
|
|
}
|
|
}))
|
|
}
|
|
return elems
|
|
}
|
|
|
|
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
|
|
// window-relative coordinate space of the GlyphLayout, where layout.Y==0 is the
|
|
// top of the VISIBLE WINDOW, not the top of the file. It adds only the sub-line
|
|
// remainder of the scroll (ScrollOffset mod lineHeight), never the full scroll:
|
|
// the full scroll would make visualLine a huge content-line number far past the
|
|
// window's line count, clamping the cursor to the bottom line of the viewport on
|
|
// any large file (it only worked by luck on small files whose window spanned the
|
|
// tapped content-line number).
|
|
// scrollDecompose splits a scroll offset s into content line k and sub-line
|
|
// remainder r such that k*lh <= s < (k+1)*lh — the floor decomposition in the
|
|
// Dp domain, computed in float64.
|
|
//
|
|
// k and r must be the SINGLE shared source of both the window start line
|
|
// (visibleByteRangePrecise/Estimate) and the sub-line draw/tap remainder
|
|
// (visibleScrollOffset in layoutFrame, tapLocalY). Computing k as
|
|
// int(s/lh) in the Dp float32 domain can round the quotient UP across an
|
|
// integer boundary while the (float64) remainder still reflects the line
|
|
// below; the two bookkeeping values then disagree by one line in a
|
|
// sub-pixel-wide band of scroll offsets, shifting the rendered window — and
|
|
// with it every tapped content line — by one. The float64 decomposition with
|
|
// the r<0 / r>=lh corrections keeps k and r consistent for every s >= 0.
|
|
// scrollVisualDecompose maps the current scroll offset to (windowStartLine,
|
|
// drawOffset) in visual-line space — the exact inverse of what the renderer
|
|
// does when it draws the window at reg.Y - drawOffset.
|
|
//
|
|
// v0 is the visual line at the viewport top; k is the logical line that
|
|
// contains it (via the WrapIndex); drawOffset = ScrollOffset - V(k)*lh, the
|
|
// amount the renderer shifts the window up. With word wrap, drawOffset may
|
|
// exceed one lineHeight: the viewport top then sits inside a wrapped line's
|
|
// continuation, and the wrapped lines that fall above the viewport are
|
|
// clipped away. Without a WrapIndex (index not built yet) or with an
|
|
// all-ones index (nothing shaped/wrapped yet), the result is exactly the
|
|
// legacy mapping (k = v0, drawOffset = ScrollOffset mod lh).
|
|
//
|
|
// Must be called on the logic goroutine (reads ScrollOffset, the last
|
|
// GlyphLayout and the buffer's WrapIndex; writes nothing).
|
|
func scrollVisualDecompose() (k int, r float64) {
|
|
lh := EffectiveLineHeight()
|
|
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
|
|
lh = gl.LineHeight
|
|
}
|
|
v0, r0 := scrollDecompose(TheState.ScrollOffset, lh)
|
|
w := (*WrapIndex)(nil)
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
w = cb.WrapIndex
|
|
}
|
|
if w == nil {
|
|
return v0, r0
|
|
}
|
|
k = w.LineForVisual(int32(v0))
|
|
vk := w.VisualsBefore(k)
|
|
// r = s - V(k)*lh = (v0 - V(k))*lh + r0: the v0 - V(k) term counts the
|
|
// wrapped continuation lines above the window start.
|
|
return k, float64(v0-int(vk))*float64(lh) + r0
|
|
}
|
|
|
|
// applyWrapCounts corrects the WrapIndex counts for the lines described by
|
|
// a shaped layout. The layout's VisualLineStarts (window-relative byte
|
|
// offsets, one per visual line start) are grouped by the logical line whose
|
|
// byte range contains each start; a wrapped logical line then carries its
|
|
// true visual-line count.
|
|
//
|
|
// The layout describes the window it was shaped for (fb.WindowStartLine /
|
|
// fb.WindowStartByte), which may differ from the CURRENT window (a scroll
|
|
// can move the window between shaping and delivery) — that is fine: the
|
|
// counts belong to real lines that are still valid as long as no edit has
|
|
// shifted them, which the caller checks via EditSeq before calling here.
|
|
//
|
|
// A logical line whose range contains no visual line start (an empty line,
|
|
// or a line the shaper produced no starts for) keeps its current count.
|
|
//
|
|
// Must be called on the logic goroutine.
|
|
func (s *State) applyWrapCounts(fb ui.LayoutFeedback) {
|
|
cb := s.Editor.ChunkedBuffer
|
|
if cb == nil || cb.WrapIndex == nil || fb.WindowStartLine < 0 {
|
|
return
|
|
}
|
|
// fb.WindowText is the exact text this layout was shaped for (carried in
|
|
// the frame). Grouping over the CURRENT window (IMEWindowText) instead
|
|
// would attribute counts to the wrong lines whenever a scroll moved the
|
|
// window between shaping and delivery.
|
|
winText := fb.WindowText
|
|
if winText == "" {
|
|
return
|
|
}
|
|
starts := fb.GlyphLayout.VisualLineStarts
|
|
if len(starts) == 0 {
|
|
return
|
|
}
|
|
// Walk the window's logical lines (delimited by '\n'); attribute each
|
|
// visual line start to the logical line containing it. Both the starts
|
|
// and the line ranges are ascending, so a single forward pointer works.
|
|
si := 0
|
|
lineStart := 0
|
|
for li := 0; ; li++ {
|
|
idx := strings.IndexByte(winText[lineStart:], '\n')
|
|
lineEnd := len(winText)
|
|
last := false
|
|
if idx >= 0 {
|
|
lineEnd = lineStart + idx + 1
|
|
} else {
|
|
last = true
|
|
}
|
|
count := 0
|
|
for si < len(starts) && int(starts[si]) < lineEnd {
|
|
if int(starts[si]) >= lineStart {
|
|
count++
|
|
}
|
|
si++
|
|
}
|
|
if count > 0 {
|
|
cb.WrapIndex.Set(fb.WindowStartLine+li, int32(count))
|
|
}
|
|
if last {
|
|
break
|
|
}
|
|
lineStart = lineEnd
|
|
}
|
|
}
|
|
|
|
func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
|
|
sf, lf := float64(s), float64(lh)
|
|
if lf <= 0 {
|
|
return 0, 0
|
|
}
|
|
k = int(sf / lf)
|
|
r = sf - float64(k)*lf
|
|
if r < 0 {
|
|
k--
|
|
r = sf - float64(k)*lf
|
|
}
|
|
if r >= lf {
|
|
k++
|
|
r = sf - float64(k)*lf
|
|
}
|
|
if k < 0 {
|
|
k, r = 0, sf
|
|
}
|
|
return k, r
|
|
}
|
|
|
|
// tapLocalY returns the text-local Y (Dp, window-relative: 0 = top of the
|
|
// visible window, matching GlyphLayout.Y) of an app-local tap at ptY.
|
|
//
|
|
// The renderer draws the window's top at reg.Y - r' (r' = the sub-line draw
|
|
// offset from scrollVisualDecompose), so a point at app-Y ptY sits at
|
|
// window-Y (ptY - regionTop) + r'. r' is the same value the renderer was
|
|
// given, so the tap maps to the drawn geometry for every scroll offset,
|
|
// font setting and wrap state — including the case where the viewport top
|
|
// sits inside a wrapped line's continuation (r' > one line).
|
|
func tapLocalY(ptY, regionTopY ui.Dp) float64 {
|
|
_, r := scrollVisualDecompose()
|
|
return float64(ptY-regionTopY) + r
|
|
}
|
|
|
|
// SetCursorFromPoint updates the cursor position based on text-local
|
|
// coordinates (Dp, window-relative: x from the text region's left edge, y as
|
|
// produced by tapLocalY). A tap is an explicit cursor placement: it always
|
|
// clears any selection.
|
|
func SetCursorFromPoint(x, y float64) {
|
|
ClearSelection()
|
|
if pos, ok := textPosFromLocalPoint(x, y); ok {
|
|
TheState.Editor.CursorPosition = pos
|
|
}
|
|
}
|
|
|
|
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
|
|
// an absolute byte offset in the buffer. ok=false when the layout is empty or
|
|
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
|
|
// selection-drag handlers.
|
|
func textPosFromLocalPoint(x, y float64) (int, bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
lineHeight := float64(EffectiveLineHeight())
|
|
|
|
// 1. Identify the intended line index based on y
|
|
// layout.Y values are relative to the text region origin.
|
|
// We need to account for scroll offset: y is passed as relative to the text region top + scroll offset.
|
|
// So y is the position in the *content*.
|
|
visualLine := int(y / lineHeight)
|
|
|
|
return textPosOnLineAtX(visualLine, x)
|
|
}
|
|
|
|
// textPosOnLineAtX maps a text-local x (Dp, same convention as
|
|
// textPosFromLocalPoint) on the given visual line to an absolute byte
|
|
// offset. Out-of-range lines clamp to the nearest non-empty line, exactly
|
|
// like the y-based selection in textPosFromLocalPoint. ok=false when the
|
|
// layout is empty or the (clamped) line has no glyphs.
|
|
func textPosOnLineAtX(visualLine int, x float64) (int, bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
base := glyphBase()
|
|
lineHeight := float64(EffectiveLineHeight())
|
|
|
|
// Group glyphs by their Y-baseline
|
|
type lineGroup struct {
|
|
y float64
|
|
indices []int
|
|
}
|
|
var groups []lineGroup // assigned in the grouping loop below
|
|
|
|
// Find all unique baseline Ys
|
|
// The tap Y is based on line height (top of line).
|
|
// We need to associate Y-baseline with visual line index.
|
|
|
|
// Create map from visual line (0, 1, 2...) to baseline Y.
|
|
// Since line height is fixed:
|
|
// Line 0 baseline is at some Y0.
|
|
// Line 1 baseline is at Y0 + lineHeight.
|
|
|
|
// Let's find Y0 first.
|
|
minY := 1e9
|
|
for _, yVal := range layout.Y {
|
|
if float64(yVal) < minY {
|
|
minY = float64(yVal)
|
|
}
|
|
}
|
|
|
|
// Now group by visual line. A glyph's visual line is resolved from the
|
|
// recorded line starts (VisualLineStarts), NOT from its Y-baseline: the
|
|
// window's first visual line may be an empty line with no recorded
|
|
// glyphs, in which case min(layout.Y) is the first NON-empty line's
|
|
// baseline and (Y-minY)/lineHeight numbers every line one too high —
|
|
// taps and dragged handles then landed one line below the finger
|
|
// whenever the viewport top sat on an empty line.
|
|
groups = []lineGroup{}
|
|
for i, yVal := range layout.Y {
|
|
yFloat := float64(yVal)
|
|
lineIdx, ok := visualLineOfByte(layout.ByteOffsets[i])
|
|
if !ok {
|
|
lineIdx = int((yFloat-minY)/lineHeight + 0.5) // round to nearest line
|
|
}
|
|
if lineIdx < 0 {
|
|
lineIdx = 0
|
|
}
|
|
|
|
// Ensure enough groups
|
|
for len(groups) <= lineIdx {
|
|
groups = append(groups, lineGroup{
|
|
y: minY + float64(len(groups))*lineHeight,
|
|
indices: []int{},
|
|
})
|
|
}
|
|
groups[lineIdx].indices = append(groups[lineIdx].indices, i)
|
|
}
|
|
|
|
// If visualLine is out of bounds, clamp
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
|
|
// A line with no glyphs: an empty line (a lone "\n"), or the trailing
|
|
// empty line of a file ending in "\n". Its single insertion point is
|
|
// the start of the line, before its terminating newline. Anchor there:
|
|
// a handle dragged across an empty line sweeps up to the line instead
|
|
// of snapping back to the last text line (or jumping past the empty
|
|
// line to the text after it).
|
|
if visualLine < len(layout.VisualLineStarts) {
|
|
return base + layout.VisualLineStarts[visualLine], true
|
|
}
|
|
// No line start recorded for this line (finger beyond the content):
|
|
// clamp to the last non-empty line.
|
|
for i := len(groups) - 1; i >= 0; i-- {
|
|
if len(groups[i].indices) > 0 {
|
|
visualLine = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
targetGroup := groups[visualLine]
|
|
|
|
// 3. Identify rightmost extent on this line
|
|
rightmostX := 0.0
|
|
rightmostIdx := -1
|
|
for _, i := range targetGroup.indices {
|
|
xEnd := float64(layout.X[i] + layout.Advance[i])
|
|
if xEnd > rightmostX {
|
|
rightmostX = xEnd
|
|
rightmostIdx = i
|
|
}
|
|
}
|
|
|
|
// lineEndByte is the insertion point at the end of the line content,
|
|
// before any trailing newline.
|
|
lineEndByte := func() (int, bool) {
|
|
start := base + layout.ByteOffsets[rightmostIdx]
|
|
buf := TheState.Editor.ChunkedBuffer
|
|
var fileContent string
|
|
if buf != nil {
|
|
content, err := buf.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full content: %v", err)
|
|
return 0, false
|
|
}
|
|
fileContent = content
|
|
} else {
|
|
fileContent = TheState.Editor.Buffer
|
|
}
|
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
|
if r == '\n' {
|
|
return start, true
|
|
}
|
|
return start + size, true
|
|
}
|
|
|
|
// 4. Check if tap is to the right of the last character
|
|
if rightmostIdx != -1 && x > rightmostX {
|
|
return lineEndByte()
|
|
}
|
|
|
|
// 5. Otherwise, map x to the nearest insertion point on the line: the
|
|
// glyph edge closest to x. x exactly on an edge maps to that edge's byte
|
|
// (this is where handle anchors sit, so a dragged handle must map back
|
|
// to its own byte), and a tie (x exactly midway between two edges) maps
|
|
// to the left byte. Tapping the right half of a glyph positions the
|
|
// caret after it, as on Android.
|
|
idxs := targetGroup.indices
|
|
// First glyph whose left edge is right of x.
|
|
m := sort.Search(len(idxs), func(k int) bool {
|
|
return float64(layout.X[idxs[k]]) > x
|
|
})
|
|
if m == 0 {
|
|
// x at or left of the first glyph's left edge.
|
|
return base + layout.ByteOffsets[idxs[0]], true
|
|
}
|
|
// x sits in the span of glyph idxs[m-1] (or, when m == len(idxs), at or
|
|
// past its left edge but within its span, since x <= rightmostX here).
|
|
g := idxs[m-1]
|
|
left := x - float64(layout.X[g])
|
|
right := float64(layout.Advance[g]) - left
|
|
if left <= right {
|
|
return base + layout.ByteOffsets[g], true
|
|
}
|
|
if m < len(idxs) {
|
|
return base + layout.ByteOffsets[idxs[m]], true
|
|
}
|
|
// Right half of the last glyph: the insertion point after it.
|
|
return lineEndByte()
|
|
}
|