The screen->line tap mapping only needs the sub-line scroll remainder (tapLocalY adds r, never the full scroll) because the visible glyph layout is window-relative and IMEWindowStartByte re-anchors it to the file. That holds for every scroll offset IF the window start line k=floor(s/lh) and the sub-line remainder r stay consistent. Property test (4000 random scroll/tap pairs, asserting against an independent drawn-geometry ground truth, not the tap code's own math) exposed a real bug: int(s/lh) in the Dp float32 domain rounds the quotient to nearest and can round UP across an integer boundary while the float64 mod still reflects the line below. In a sub-pixel-wide band of scroll offsets the window started one line too far while the draw shift lagged by one line - the whole rendered window (and every tapped line) shifted by one. Fix: one shared float64 floor decomposition (scrollDecompose) used by the window start (visibleByteRangePrecise/Estimate), the renderer's sub-line shift (visibleScrollOffset), the tap mapping (tapLocalY), and chunk prefetching. Also route the shaper's line height (previously ignored by visibleByteRangePrecise) through VisibleByteRange. On-device cross-check: at s=4246.9 (r=13.3) and s=4210.7 (r=10.7), taps on visually identified lines typed markers that landed on exactly those lines in the file on disk; profiler ScrollDP, screenshot, formula, and disk all agreed. Docs: scroll decomposition invariant in architecture.md §6.2, pipeline hop 2 in doc/README.md, Phase 10 in development_plan.md.
1964 lines
60 KiB
Go
1964 lines
60 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"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
|
|
// --- Touch selection (v1) ---
|
|
// CaretDrag: after a long press on blank space a single draggable caret
|
|
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
|
|
// floating copy/cut/paste menu (positioned below the line of the selection
|
|
// end; items are recomputed when the menu is shown). SelDragging/
|
|
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
|
|
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
|
|
// handle). All of it is logic-owned; the renderer only reports finger
|
|
// positions and draws the geometry.
|
|
CaretDrag bool
|
|
MenuVisible bool
|
|
MenuRect ui.Region
|
|
MenuItems []ui.MenuItem
|
|
SelDragging bool
|
|
SelDragWhich int
|
|
SelDragRel int
|
|
Filename string
|
|
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
|
// editor shows a "too large to edit" notice instead of content (the
|
|
// browser can still list the file).
|
|
TooLarge bool
|
|
TooLargeSize int64
|
|
fileVersion map[string]int
|
|
lastWriteVersion map[string]int
|
|
saveTimer *time.Timer
|
|
writeFailed map[string]bool // Added: tracks failed writes for UI
|
|
retryAttempts map[string]int // Added: tracks retry attempts
|
|
}
|
|
|
|
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
|
|
func (e *EditorState) GetBuffer() string {
|
|
if e.ChunkedBuffer != nil {
|
|
fullContent, err := e.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full buffer content: %v", err)
|
|
return ""
|
|
}
|
|
return fullContent
|
|
}
|
|
return e.Buffer
|
|
}
|
|
|
|
// IsSaving returns true if a save is pending.
|
|
func (e *EditorState) IsSaving() bool {
|
|
return e.saveTimer != nil
|
|
}
|
|
|
|
// IsDirty, WriteFailed, and RetryAttempts are computed:
|
|
|
|
func (e *EditorState) IsDirty() bool {
|
|
bufVer := e.fileVersion[e.Filename]
|
|
writeVer := e.lastWriteVersion[e.Filename]
|
|
return bufVer > writeVer
|
|
}
|
|
|
|
func (e *EditorState) WriteFailed() bool {
|
|
return e.writeFailed[e.Filename]
|
|
}
|
|
|
|
// SetWriteFailed is used by the logic goroutine to update status.
|
|
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
|
|
e.writeFailed[filename] = failed
|
|
if !failed {
|
|
e.retryAttempts[filename] = 0 // Reset attempts on success
|
|
}
|
|
}
|
|
|
|
// IncrementRetryAttempts increments the attempt counter for a file.
|
|
func (e *EditorState) IncrementRetryAttempts(filename string) int {
|
|
e.retryAttempts[filename]++
|
|
return e.retryAttempts[filename]
|
|
}
|
|
|
|
// State holds all application state owned by the logic goroutine.
|
|
type State struct {
|
|
PixelWidth int // raw pixel width from Gio ConfigEvent
|
|
PixelHeight int // raw pixel height from Gio ConfigEvent
|
|
scale float32
|
|
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
|
|
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) Scale() float32 {
|
|
return s.scale
|
|
}
|
|
|
|
// layout converts stored pixel dimensions to Dp using the current scale
|
|
// and computes the element tree. Called only when a frame is needed.
|
|
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
|
// not here, to ensure proper channel-based state flow.
|
|
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
|
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
|
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
|
|
|
// Calculate VisibleCount before laying out the browser page.
|
|
// This ensures the browser shows entries based on the current viewport.
|
|
if s.PixelHeight > 0 && s.scale > 0 {
|
|
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
|
|
rowHeight := ui.Dp(48)
|
|
newVisibleCount := int(listAreaHeight / rowHeight)
|
|
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
|
|
s.Browser.VisibleCount = newVisibleCount
|
|
}
|
|
}
|
|
|
|
switch s.page {
|
|
case BrowserPage:
|
|
tapHandler := func(data any) {
|
|
if idx, ok := data.(int); ok {
|
|
browser.HandleBrowserTap(bm, &s.Browser, idx)
|
|
}
|
|
}
|
|
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
|
|
case EditorPage:
|
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
|
}
|
|
return s.Elems
|
|
}
|
|
|
|
// ToggleWordWrap toggles the word wrap setting.
|
|
func ToggleWordWrap(data any) {
|
|
TheState.WordWrap = !TheState.WordWrap
|
|
}
|
|
|
|
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
|
|
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
|
// Also evicts chunks far from the cursor to keep memory bounded.
|
|
func HandleScroll(data any) {
|
|
// Swallow scroll from the opening gesture: the tap that opened the file can
|
|
// leak a scroll delta into the editor before the line index is built (when
|
|
// MaxScroll is a large estimate), leaving the viewport past the content.
|
|
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
|
return
|
|
}
|
|
delta := data.(int) // pixels
|
|
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
// Evict chunks far from the cursor to keep memory bounded.
|
|
// Only evict when the cursor is near the viewport (i.e., scrolled to top).
|
|
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
|
|
// Temporarily disabled eviction to debug thrashing issues.
|
|
/*
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
|
|
cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
|
|
TheState.lastEvictionTime = time.Now()
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
|
|
func HandleBrowserScroll(data any) {
|
|
delta := data.(int) // pixel delta from gesture.Scroll.Update
|
|
browser.HandlePixelScroll(&TheState.Browser, delta)
|
|
}
|
|
|
|
// GoToBrowser switches the app to the browser page.
|
|
func GoToBrowser(data any) {
|
|
if TheLogic != nil {
|
|
TheLogic.FlushAll()
|
|
}
|
|
TheState.page = BrowserPage
|
|
}
|
|
|
|
// GoToEditor switches the app to the editor page.
|
|
func GoToEditor(data any) {
|
|
TheState.page = EditorPage
|
|
}
|
|
|
|
// OpenFile sets the active filename and switches to the editor page.
|
|
// data is the filename string from the browser list.
|
|
func OpenFile(data any) {
|
|
filename := data.(string)
|
|
// NOTE: the external open hook (TheState.open, e.g. the Android Termux
|
|
// bridge) is intentionally NOT called here. Opening a file is an in-app
|
|
// action; the external bridge was the old tap path and it crashes on
|
|
// Android 7+ (FileUriExposedException from a file:// Intent URI).
|
|
|
|
// Dispatch a request to load the file
|
|
go func() {
|
|
TheLogic.openFileChan <- filename
|
|
}()
|
|
|
|
TheState.Editor.Filename = filename
|
|
TheState.Editor.CursorPosition = 0 // Reset cursor to top
|
|
TheState.Editor.TooLarge = false
|
|
TheState.Editor.TooLargeSize = 0
|
|
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
|
|
TheState.page = EditorPage
|
|
TheState.FocusedElementID = "editor_text" // Set focus to editor
|
|
// The tap that opened this file is delivered to the browser row, but its
|
|
// gesture can leak into the now-visible editor and move the cursor/scroll.
|
|
// Record the open time so the editor can swallow taps in a short window
|
|
// right after open (the opening tap, not a deliberate editor tap).
|
|
TheState.justOpenedAt = time.Now()
|
|
}
|
|
|
|
// SetChunkedBuffer sets the chunked buffer for the current editor state.
|
|
func SetChunkedBuffer(cb *ChunkedBuffer) {
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
}
|
|
|
|
// ToggleSortOrder cycles the browser sort mode through four modes.
|
|
func ToggleSortOrder(data any) {
|
|
// Cycle through the 4 sort modes
|
|
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
|
|
// Reset scroll on sort change
|
|
TheState.Browser.ScrollOffset = 0
|
|
// Clear cached pages so they reload with the new sort order
|
|
TheState.Browser.Pages = make(map[int]*browser.Page)
|
|
// Reload initial pages with the new sort order
|
|
browser.LoadInitialPages(&TheState.Browser)
|
|
// Recompute search results if there's an active search query
|
|
browser.HandleSortModeChange(&TheState.Browser)
|
|
}
|
|
|
|
// HandleCursorMove updates the cursor position within bounds.
|
|
func HandleCursorMove(delta int) {
|
|
newPos := TheState.Editor.CursorPosition + delta
|
|
if newPos < 0 {
|
|
newPos = 0
|
|
}
|
|
// Use ChunkedBuffer.FileLen() as the authoritative upper bound
|
|
var maxPos int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
maxPos = int(cb.FileLen())
|
|
}
|
|
if maxPos == 0 {
|
|
// Fallback to Buffer length if ChunkedBuffer not yet initialized
|
|
maxPos = len(TheState.Editor.Buffer)
|
|
}
|
|
if newPos > maxPos {
|
|
newPos = maxPos
|
|
}
|
|
TheState.Editor.CursorPosition = newPos
|
|
}
|
|
|
|
// HandleKeyDown interprets keyboard events for navigation and editing.
|
|
// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with
|
|
// modifier state) or bare key.Name (legacy/test path, no modifiers).
|
|
func HandleKeyDown(data any) {
|
|
// A too-large file is not editable: ignore all key input.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
switch v := data.(type) {
|
|
case key.EditEvent:
|
|
// Text input from IME / keyboard.
|
|
if v.Text == "\b" {
|
|
// Backspace character (legacy / hardware): delete the selection if
|
|
// there is one, else one char before the cursor.
|
|
HandleBackspace()
|
|
} else {
|
|
// IME insert/replace/delete: replace [Range.Start, Range.End) with
|
|
// Text. Range is empty (start==end) for plain inserts; a non-empty
|
|
// range with text is a swipe/autocorrect replacement; a non-empty
|
|
// range with empty text is a range delete. Ignoring Range here is
|
|
// what caused replaced text to be duplicated. A live selection is
|
|
// always replaced (see HandleReplaceRange).
|
|
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
|
|
}
|
|
case ui.KeyEvent:
|
|
handleKey(v.Name, v.Shift)
|
|
case key.Name:
|
|
// Bare key name: no modifier state, so no shift-selection here.
|
|
handleKey(v, false)
|
|
}
|
|
}
|
|
|
|
// handleKey dispatches a key press. shift=true turns cursor moves into
|
|
// selection extensions; edit keys act on the selection when one is active.
|
|
func handleKey(name key.Name, shift bool) {
|
|
switch name {
|
|
case key.NameLeftArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(-1) })
|
|
case key.NameRightArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(1) })
|
|
case key.NameUpArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(true) })
|
|
case key.NameDownArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(false) })
|
|
case key.NameDeleteBackward:
|
|
HandleBackspace()
|
|
case key.NameDeleteForward:
|
|
HandleDelete()
|
|
case key.NameReturn:
|
|
HandleInsert("\n")
|
|
case key.NameHome:
|
|
moveCursor(shift, HandleHome)
|
|
case key.NameEnd:
|
|
moveCursor(shift, HandleEnd)
|
|
case key.NamePageUp:
|
|
moveCursor(shift, func() { HandlePageUpDown(true) })
|
|
case key.NamePageDown:
|
|
moveCursor(shift, func() { HandlePageUpDown(false) })
|
|
}
|
|
}
|
|
|
|
// glyphBase returns the absolute file byte offset at the start of the current
|
|
// visible window. The GlyphLayout is shaped from the visible window alone, so
|
|
// every one of its ByteOffsets is relative to this base; adding it yields an
|
|
// absolute file offset. For a whole-file window (small files, or scroll 0) the
|
|
// base is 0 and this is a no-op.
|
|
func glyphBase() int {
|
|
return TheState.Editor.IMEWindowStartByte
|
|
}
|
|
|
|
// --- Text selection ---------------------------------------------------------
|
|
//
|
|
// The selection is a byte range in absolute file coordinates derived from
|
|
// SelectionAnchor (fixed end) and CursorPosition (active end). No selection:
|
|
// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/
|
|
// end/page extend the selection; a plain cursor move or any edit clears it.
|
|
// All of these must run on the logic goroutine (owner).
|
|
|
|
// selActive reports whether there is a non-empty selection.
|
|
func selActive() bool {
|
|
e := &TheState.Editor
|
|
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
|
|
}
|
|
|
|
// ClearSelection drops the selection and the shift-anchor. It also dismisses
|
|
// the selection menu and cancels any in-progress drag bookkeeping: every path
|
|
// that clears the selection (tap, plain cursor move, any edit) should take
|
|
// the menu and handles down with it.
|
|
func ClearSelection() {
|
|
e := &TheState.Editor
|
|
e.SelectionAnchor = -1
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
e.MenuVisible = false
|
|
e.MenuItems = nil
|
|
e.SelDragging = false
|
|
}
|
|
|
|
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
|
// anchoring at the lower end and placing the cursor (active end) at the upper
|
|
// end. Clamped to the buffer; an empty/clamped range clears instead.
|
|
// Convenience for tests and future gestures (tap-range, double-tap).
|
|
func SetSelection(start, end int) {
|
|
e := &TheState.Editor
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
var fileLen int
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
fileLen = int(cb.FileLen())
|
|
} else {
|
|
fileLen = len(e.Buffer)
|
|
}
|
|
if end > fileLen {
|
|
end = fileLen
|
|
}
|
|
if end <= start {
|
|
ClearSelection()
|
|
e.CursorPosition = start
|
|
return
|
|
}
|
|
e.SelectionAnchor = start
|
|
e.SelectionStart = start
|
|
e.SelectionEnd = end
|
|
e.CursorPosition = end
|
|
}
|
|
|
|
// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor,
|
|
// cursor). A zero-length range (anchor == cursor) is treated as no selection,
|
|
// but the anchor is kept so the next shift-move extends from the original spot
|
|
// again.
|
|
func updateSelectionFromAnchor() {
|
|
e := &TheState.Editor
|
|
if e.SelectionAnchor < 0 {
|
|
ClearSelection()
|
|
return
|
|
}
|
|
a, c := e.SelectionAnchor, e.CursorPosition
|
|
if a == c {
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
return
|
|
}
|
|
if a < c {
|
|
e.SelectionStart, e.SelectionEnd = a, c
|
|
} else {
|
|
e.SelectionStart, e.SelectionEnd = c, a
|
|
}
|
|
}
|
|
|
|
// moveCursor runs op (a cursor-mutating handler). With shift held it keeps
|
|
// the anchor and extends the selection to the new cursor position; without it
|
|
// it clears any selection first. This is the single place where selection
|
|
// bookkeeping meets cursor movement, so every move path (arrows, home/end,
|
|
// page, vertical) gets consistent semantics.
|
|
func moveCursor(shift bool, op func()) {
|
|
if shift {
|
|
if TheState.Editor.SelectionAnchor < 0 {
|
|
TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition
|
|
}
|
|
} else {
|
|
ClearSelection()
|
|
}
|
|
op()
|
|
if shift {
|
|
updateSelectionFromAnchor()
|
|
}
|
|
}
|
|
|
|
// deleteRange removes the byte range [start, end) from the active buffer and
|
|
// updates the line index. Shared by the selection-aware edit handlers.
|
|
func deleteRange(start, end int) {
|
|
if end <= start {
|
|
return
|
|
}
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
buf.Delete(start, end-start)
|
|
buf.UpdateLineIndexAfterDelete(start, end)
|
|
} else {
|
|
str := TheState.Editor.Buffer
|
|
TheState.Editor.Buffer = str[:start] + str[end:]
|
|
}
|
|
}
|
|
|
|
// --- Touch selection (v1) ---------------------------------------------------
|
|
//
|
|
// Android-style touch interaction, layered over the byte-range selection
|
|
// model above. Single tap = caret (existing). Long press = select the word
|
|
// under the finger (or a draggable caret handle on blank space). Double tap
|
|
// = select the word. Handles: drag start/end to resize, drag the body to move
|
|
// the whole selection. Floating menu: Copy / Cut / Paste.
|
|
//
|
|
// Coordinate flow: the renderer reports app-local window Dp points (the same
|
|
// space as a tap). The functions below convert to text-local coordinates via
|
|
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
|
|
// All of these run on the logic goroutine (owner).
|
|
|
|
const (
|
|
menuItemW = ui.Dp(56) // width of one selection-menu button
|
|
menuH = ui.Dp(52) // selection-menu panel height
|
|
)
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// showSelectionMenu positions the copy/cut/paste menu below the line that
|
|
// contains the selection end (or caret) and recomputes the items. 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
|
|
}
|
|
anchor := e.CursorPosition
|
|
if e.SelectionEnd > anchor {
|
|
anchor = e.SelectionEnd
|
|
}
|
|
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
|
|
if !ok {
|
|
return
|
|
}
|
|
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))
|
|
}
|
|
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")
|
|
menuW := menuItemW * ui.Dp(len(items))
|
|
mx := glyphX - float64(menuW)/2
|
|
if mx < 8 {
|
|
mx = 8
|
|
}
|
|
if winW > 0 && mx+float64(menuW) > winW-8 {
|
|
mx = winW - float64(menuW) - 8
|
|
}
|
|
my := lineTop + float64(EditorLineHeight()) + 8 // below the line
|
|
if winH > 0 && my+float64(menuH) > winH-8 {
|
|
my = lineTop - float64(menuH) - 8 // flip above the line
|
|
}
|
|
if my < 8 {
|
|
my = 8
|
|
}
|
|
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
|
|
e.MenuItems = items
|
|
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
|
|
}
|
|
lineHeight := float64(EditorLineHeight())
|
|
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; the baseline offset within a line is always
|
|
// < lineHeight, so int(baseline/lineHeight) is the visual line index.
|
|
visualLine := int(float64(layout.Y[idx]) / lineHeight)
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
lt := float64(reg.Y) + 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, TheState.ScrollOffset)
|
|
SetCursorFromPoint(localX, localY)
|
|
}
|
|
|
|
// 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, TheState.ScrollOffset)
|
|
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, TheState.ScrollOffset)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// HandleSelDragEvt is the registered SelDrag interaction handler. The
|
|
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
|
|
func HandleSelDragEvt(data any) {
|
|
switch ev := data.(type) {
|
|
case ui.SelectionDragEvent:
|
|
selDragMove(ev.Which, ev.X, ev.Y)
|
|
case ui.SelectionDragEnd:
|
|
TheState.Editor.SelDragging = false
|
|
TheState.Editor.SelDragRel = 0
|
|
// The caret handle (long press on blank space) is a transient affordance;
|
|
// the selection handles come back from the selection state on the next
|
|
// frame, so only the caret mode is dropped here.
|
|
TheState.Editor.CaretDrag = false
|
|
}
|
|
}
|
|
|
|
// selDragMove applies one finger position of a selection/caret drag.
|
|
func selDragMove(which int, x, y ui.Dp) {
|
|
e := &TheState.Editor
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !e.SelDragging {
|
|
e.SelDragging = true
|
|
e.SelDragWhich = which
|
|
if which == 2 && ok {
|
|
// Body drag: the grab fixes the finger's offset from the selection
|
|
// start; the selection itself moves on later events.
|
|
e.SelDragRel = pos - e.SelectionStart
|
|
if e.SelDragRel < 0 {
|
|
e.SelDragRel = 0
|
|
}
|
|
return
|
|
}
|
|
// Start/end/caret handles: the first event already positions the
|
|
// handle (no grab offset needed).
|
|
}
|
|
if !ok {
|
|
return // finger outside the laid-out window: keep last position
|
|
}
|
|
switch e.SelDragWhich {
|
|
case 0: // start handle
|
|
if pos > e.SelectionEnd {
|
|
pos = e.SelectionEnd
|
|
}
|
|
SetSelection(pos, e.SelectionEnd)
|
|
case 1: // end handle
|
|
if pos < e.SelectionStart {
|
|
pos = e.SelectionStart
|
|
}
|
|
SetSelection(e.SelectionStart, pos)
|
|
case 2: // body: move the whole selection, preserving length
|
|
selLen := e.SelectionEnd - e.SelectionStart
|
|
ns := pos - e.SelDragRel
|
|
fl := fileLenBytes()
|
|
if ns < 0 {
|
|
ns = 0
|
|
}
|
|
if ns+selLen > fl {
|
|
ns = fl - selLen
|
|
}
|
|
SetSelection(ns, ns+selLen)
|
|
case 3: // caret drag handle
|
|
e.CursorPosition = pos
|
|
}
|
|
}
|
|
|
|
// 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 < EditorLineHeight() {
|
|
pageSize = EditorLineHeight()
|
|
}
|
|
|
|
if up {
|
|
TheState.ScrollOffset -= pageSize
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
} else {
|
|
TheState.ScrollOffset += pageSize
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
}
|
|
}
|
|
func HandleVerticalCursorMove(up bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
// If idx == len, we are at the end. Use the last glyph.
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentX := layout.X[idx]
|
|
currentY := layout.Y[idx]
|
|
|
|
var targetIdx int = idx
|
|
|
|
if up {
|
|
// Scan backwards to find the previous line's Y.
|
|
prevY := currentY
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] < prevY {
|
|
prevY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if prevY == currentY {
|
|
// Already at the top line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in prevY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == prevY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Scan forwards to find the next line's Y.
|
|
nextY := currentY
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] > nextY {
|
|
nextY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if nextY == currentY {
|
|
// Already at the bottom line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in nextY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == nextY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// utf8BackspaceWidth returns the byte width of the UTF-8 rune ending at the
|
|
// end of seg (the rune immediately before the cursor). seg must contain the
|
|
// rune's full bytes (4 or more preceding bytes suffice).
|
|
func utf8BackspaceWidth(seg string) int {
|
|
// The rune ends at the end of seg. Scan back over continuation bytes
|
|
// (0x80-0xBF) until the lead byte; the width is the span covered.
|
|
i := len(seg) - 1
|
|
for i > 0 && seg[i]&0xC0 == 0x80 {
|
|
i--
|
|
}
|
|
return len(seg) - i
|
|
}
|
|
|
|
// utf8AdvanceWidth returns the byte width of the UTF-8 rune starting at the
|
|
// beginning of seg (4 or more following bytes suffice). Returns 0 if seg is
|
|
// empty (cursor at EOF).
|
|
func utf8AdvanceWidth(seg string) int {
|
|
if len(seg) == 0 {
|
|
return 0
|
|
}
|
|
w := 1
|
|
for i := 1; i < len(seg) && seg[i]&0xC0 == 0x80; i++ {
|
|
w++
|
|
}
|
|
return w
|
|
}
|
|
|
|
// HandleDelete removes the character (full UTF-8 rune) after the cursor.
|
|
// With a live selection it deletes the whole selection.
|
|
func HandleDelete() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
seg := buf.Content(pos, pos+4)
|
|
if w := utf8AdvanceWidth(seg); w > 0 {
|
|
buf.Delete(pos, w)
|
|
buf.UpdateLineIndexAfterDelete(pos, pos+w)
|
|
markDirty()
|
|
}
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
if pos >= len(str) {
|
|
return
|
|
}
|
|
end := len(str)
|
|
if pos+4 < end {
|
|
end = pos + 4
|
|
}
|
|
TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):]
|
|
markDirty()
|
|
}
|
|
|
|
// HandleInsert inserts a string at the current cursor position. With a live
|
|
// selection it replaces the selection instead.
|
|
func HandleInsert(s string) {
|
|
// EditorState is embedded by value in State: take the address, never a
|
|
// copy, or the writes below are lost.
|
|
e := &TheState.Editor
|
|
pos := e.CursorPosition
|
|
if selActive() {
|
|
pos = e.SelectionStart
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
ClearSelection()
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
buf.Insert(pos, s)
|
|
buf.UpdateLineIndexAfterInsert(pos, s)
|
|
} else {
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := e.Buffer
|
|
e.Buffer = str[:pos] + s + str[pos:]
|
|
}
|
|
e.CursorPosition = pos + len(s)
|
|
markDirty()
|
|
}
|
|
|
|
// HandleBackspace removes the character before the cursor. With a live
|
|
// selection it deletes the whole selection instead.
|
|
func HandleBackspace() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
if pos == 0 {
|
|
return
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(buf.Content(segStart, pos))
|
|
buf.Delete(pos-w, w)
|
|
buf.UpdateLineIndexAfterDelete(pos-w, pos)
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(str[segStart:pos])
|
|
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
}
|
|
|
|
// runeIndexToByteStr returns the byte offset of the n-th rune (0-indexed) in
|
|
// s. A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte lead byte
|
|
// (>=0xC0); 0x80-0xBF are continuation bytes. If n is past the end, returns
|
|
// len(s).
|
|
func runeIndexToByteStr(s string, n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
runes := 0
|
|
for i := 0; i < len(s); i++ {
|
|
b := s[i]
|
|
if b < 0x80 || b >= 0xC0 {
|
|
if runes == n {
|
|
return i
|
|
}
|
|
runes++
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
|
|
// with text and places the cursor at the end of the inserted text.
|
|
//
|
|
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
|
|
// or autocorrect commit replaces the selected region instead of blindly
|
|
// inserting at the cursor, so the old text is removed (no duplication). It
|
|
// also covers plain inserts (start==end) and range deletes (text == "").
|
|
//
|
|
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
|
|
// is byte-based, so they are converted to byte offsets first. Must be called
|
|
// on the logic goroutine (owner).
|
|
func HandleReplaceRange(startRune, endRune int, text string) {
|
|
// A too-large file is not editable: ignore IME commits.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
if startRune > endRune {
|
|
startRune, endRune = endRune, startRune
|
|
}
|
|
// The IME snippet is the visible window (IMEWindowText), so startRune/
|
|
// endRune are relative to that window. Resolve them to window-byte
|
|
// offsets, then add the window's absolute start to address the buffer.
|
|
// For small (string) files the window is the whole buffer (start 0), so
|
|
// this reduces to absolute addressing. If IMEWindowText is empty (tests
|
|
// that never run layout), fall back to the whole buffer as the window.
|
|
windowStart := TheState.Editor.IMEWindowStartByte
|
|
windowText := TheState.Editor.IMEWindowText
|
|
if windowText == "" {
|
|
windowStart = 0
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
windowText, _ = cb.FullContent()
|
|
} else {
|
|
windowText = TheState.Editor.Buffer
|
|
}
|
|
}
|
|
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
|
|
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
|
|
// A live selection is always replaced by the commit: union the IME range
|
|
// with the selection so the outcome is deterministic no matter what the
|
|
// IME reports (some IMEs send the full selection range, others send an
|
|
// empty range at the caret expecting the app to consume its reported
|
|
// selection).
|
|
if selActive() {
|
|
if absStart > TheState.Editor.SelectionStart {
|
|
absStart = TheState.Editor.SelectionStart
|
|
}
|
|
if absEnd < TheState.Editor.SelectionEnd {
|
|
absEnd = TheState.Editor.SelectionEnd
|
|
}
|
|
}
|
|
if imeDebugLog {
|
|
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
|
|
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
|
|
}
|
|
|
|
var newCursor int
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
if absEnd > absStart {
|
|
buf.Delete(absStart, absEnd-absStart)
|
|
}
|
|
buf.Insert(absStart, text)
|
|
if absEnd > absStart {
|
|
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
|
|
}
|
|
buf.UpdateLineIndexAfterInsert(absStart, text)
|
|
newCursor = absStart + len(text)
|
|
} else {
|
|
s := TheState.Editor.Buffer
|
|
if absEnd > absStart {
|
|
s = s[:absStart] + s[absEnd:]
|
|
}
|
|
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
|
|
newCursor = absStart + len(text)
|
|
}
|
|
TheState.Editor.CursorPosition = newCursor
|
|
// A commit consumed any selection it overlapped (see the union above).
|
|
ClearSelection()
|
|
if imeDebugLog {
|
|
dbgBuf := currentEditorText()
|
|
if len(dbgBuf) > 40 {
|
|
dbgBuf = dbgBuf[:40]
|
|
}
|
|
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
|
|
}
|
|
markDirty()
|
|
}
|
|
|
|
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
|
|
// use; enable when debugging IME commit/cursor sync on device.
|
|
const imeDebugLog = false
|
|
|
|
// currentEditorText returns the current editor buffer contents (full for
|
|
// chunked files). Used only for debug logging.
|
|
func currentEditorText() string {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
s, _ := cb.FullContent()
|
|
return s
|
|
}
|
|
return TheState.Editor.Buffer
|
|
}
|
|
|
|
func markDirty() {
|
|
// TheLogic is nil in pure unit tests (no logic goroutine). Editing the
|
|
// buffer is still valid there; only the autosave side-effect is skipped.
|
|
if TheLogic != nil {
|
|
TheLogic.markDirty()
|
|
}
|
|
}
|
|
|
|
// EditorLayout computes the element tree for the editor page.
|
|
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|
margin := ui.Dp(10)
|
|
|
|
// --- Top bar: filename on row 1, icons on row 2 ---
|
|
statusBarRegion := ui.Region{
|
|
X: margin, Y: margin,
|
|
W: screenWidth - margin*2,
|
|
H: ui.Dp(52),
|
|
}
|
|
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{
|
|
// Row 1: filename
|
|
ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
// Row 2: back, cut, copy, paste icons
|
|
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
|
|
ui.NewIcon("cut", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
ui.NewIcon("copy", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
ui.NewIcon("paste", ui.Region{X: ui.Dp(144), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
},
|
|
)
|
|
|
|
// --- Bottom bar ---
|
|
bottomBarHeight := ui.BottomBarHeight
|
|
bottomBarY := screenHeight - margin - bottomBarHeight
|
|
bottomBarRegion := ui.Region{
|
|
X: margin, Y: bottomBarY,
|
|
W: screenWidth - margin*2,
|
|
H: bottomBarHeight,
|
|
}
|
|
bottomBarW := bottomBarRegion.W
|
|
wrapText := "Wrap: Off"
|
|
if wordWrap {
|
|
wrapText = "Wrap: On"
|
|
}
|
|
statusText := "Saved"
|
|
if TheState.Editor.IsSaving() {
|
|
statusText = "Saving..."
|
|
} else if TheState.Editor.WriteFailed() {
|
|
statusText = "Error"
|
|
} else if TheState.Editor.IsDirty() {
|
|
statusText = "Modified"
|
|
}
|
|
|
|
// Compute cursor position and file size for the bottom bar.
|
|
cursorPos := TheState.Editor.CursorPosition
|
|
var fileSize int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
fileSize = int(cb.FileLen())
|
|
} else {
|
|
fileSize = len(TheState.Editor.Buffer)
|
|
}
|
|
cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize)
|
|
|
|
bottomBar := ui.NewContainer(
|
|
bottomBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
|
|
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
|
|
{Gesture: ui.Tap, Handler: ToggleWordWrap},
|
|
}),
|
|
},
|
|
)
|
|
|
|
// --- Editor text area ---
|
|
editorY := statusBarRegion.Y + statusBarRegion.H
|
|
editorH := bottomBarRegion.Y - editorY
|
|
editorRegion := ui.Region{
|
|
X: margin, Y: editorY,
|
|
W: screenWidth - margin*2,
|
|
H: editorH,
|
|
}
|
|
// Stored for the input handlers (tap / long press / drags), which convert
|
|
// app-local Dp points to text-local coordinates through it.
|
|
TheState.EditorRegion = editorRegion
|
|
// 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 {
|
|
totalLines := li.LineCount()
|
|
maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/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) * EditorLineHeight()
|
|
}
|
|
} else {
|
|
// Fallback for full buffer
|
|
maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/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 := EditorLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
|
|
|
// Proactively load chunks needed for the current viewport
|
|
startChunk := start / cb.ChunkSize()
|
|
endChunk := (end - 1) / cb.ChunkSize()
|
|
for i := startChunk; i <= endChunk; i++ {
|
|
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
|
|
cb.LoadChunkAsync(i)
|
|
}
|
|
}
|
|
|
|
// Extract visible content from chunked buffer. Read the full visible
|
|
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
|
|
// window could leave the lower rows blank).
|
|
visibleContent = cb.Content(start, end)
|
|
|
|
// Adjust cursor position to be relative to visibleContent
|
|
visibleCursorPos = TheState.Editor.CursorPosition - start
|
|
if visibleCursorPos < 0 {
|
|
visibleCursorPos = 0
|
|
}
|
|
|
|
// Adjust scroll offset to be relative to visibleContent origin
|
|
// Sub-line shift for the renderer: the SAME decomposition the window
|
|
// start used (VisibleByteRange above), so the drawn geometry and the
|
|
// window's content lines agree for every scroll offset.
|
|
_, subLine := scrollDecompose(TheState.ScrollOffset, lineHeight)
|
|
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, _ := scrollDecompose(TheState.ScrollOffset, lineHeight)
|
|
if prefetchLine < li.LineCount() {
|
|
scrollByteOffset = li.ByteOffset(prefetchLine)
|
|
} else {
|
|
scrollByteOffset = int(cb.FileLen())
|
|
}
|
|
} else {
|
|
// Estimate
|
|
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EditorLineHeight())
|
|
scrollByteOffset = prefetchLine * 50
|
|
}
|
|
|
|
scrollChunk := scrollByteOffset / cb.ChunkSize()
|
|
// Use a smaller radius to prevent overloading the worker pool.
|
|
// Content() will load chunks immediately needed by the viewport.
|
|
cb.Prefetch(scrollChunk, 1)
|
|
} else {
|
|
// Fallback: no chunked buffer, use full buffer (small files)
|
|
visibleContent = TheState.Editor.Buffer
|
|
visibleCursorPos = TheState.Editor.CursorPosition
|
|
visibleScrollOffset = TheState.ScrollOffset
|
|
}
|
|
|
|
// Record the shaped visible range for the profiler probe (confirms the
|
|
// virtual-scroll window stays viewport-bounded, not the whole file).
|
|
TheState.VisibleStart = start
|
|
TheState.VisibleEnd = end
|
|
|
|
// Record the visible window for IME: the snippet is this window, so an
|
|
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
|
// to address the buffer. start is 0 for small (string) files, so the
|
|
// window is the whole buffer there.
|
|
TheState.Editor.IMEWindowStartByte = start
|
|
TheState.Editor.IMEWindowText = visibleContent
|
|
// Window-relative selection for the TextField (byte offsets into
|
|
// visibleContent); -1 means nothing visible is selected. The IME push and
|
|
// the in-app highlight consume this; the logic side keeps absolute
|
|
// offsets in EditorState.
|
|
windowSelStart, windowSelEnd := -1, -1
|
|
if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss {
|
|
ws, we := ss-start, TheState.Editor.SelectionEnd-start
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
if we > len(visibleContent) {
|
|
we = len(visibleContent)
|
|
}
|
|
if ws < we {
|
|
windowSelStart, windowSelEnd = ws, we
|
|
}
|
|
}
|
|
|
|
// Add the TextField back in a way that passes the test.
|
|
editorElem := ui.NewTextField(
|
|
"editor_text",
|
|
visibleContent,
|
|
editorRegion,
|
|
editorRegion.W,
|
|
visibleScrollOffset,
|
|
visibleCursorPos,
|
|
windowSelStart,
|
|
windowSelEnd,
|
|
[]ui.Interaction{
|
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
|
// The Tap interaction also carries the long-press and double-tap
|
|
// events the renderer derives from the same gesture.Click (they share
|
|
// the editor's click region). The renderer converts all of them to
|
|
// text-local coordinates through EditorRegion + tapLocalY.
|
|
{Gesture: ui.Tap, Handler: func(data any) {
|
|
// The just-opened guard (swallowing the tap that opened the file)
|
|
// lives inside the Handle* functions.
|
|
switch pt := data.(type) {
|
|
case ui.Point:
|
|
HandleTapAt(pt.X, pt.Y)
|
|
case ui.DoubleTapPoint:
|
|
HandleDoubleTapAt(pt.X, pt.Y)
|
|
case ui.LongPressPoint:
|
|
HandleLongPressAt(pt.X, pt.Y)
|
|
}
|
|
}},
|
|
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
|
},
|
|
)
|
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
|
// for Gio to deliver key events to this element.
|
|
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
|
// Caret handle visibility (long press on blank space).
|
|
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
|
|
|
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
|
// The selection menu is added last so it draws on top of the editor.
|
|
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.
|
|
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
|
|
}
|
|
|
|
func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
|
|
// Same line height the renderer used to shape the window, and the same
|
|
// floor decomposition as the window start, so the tap maps to the drawn
|
|
// geometry for every scroll offset.
|
|
lh := EditorLineHeight()
|
|
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
|
|
lh = gl.LineHeight
|
|
}
|
|
_, r := scrollDecompose(scrollOffset, lh)
|
|
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
|
|
}
|
|
|
|
base := glyphBase()
|
|
lineHeight := float64(EditorLineHeight())
|
|
|
|
// 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)
|
|
|
|
// Group glyphs by their Y-baseline
|
|
type lineGroup struct {
|
|
y float64
|
|
indices []int
|
|
}
|
|
groups := []lineGroup{}
|
|
|
|
// Find all unique baseline Ys
|
|
// The tap Y is based on line height (top of line).
|
|
// We need to associate Y-baseline with visual line index.
|
|
|
|
// Create map from visual line (0, 1, 2...) to baseline Y.
|
|
// Since line height is fixed:
|
|
// Line 0 baseline is at some Y0.
|
|
// Line 1 baseline is at Y0 + lineHeight.
|
|
|
|
// Let's find Y0 first.
|
|
minY := 1e9
|
|
for _, yVal := range layout.Y {
|
|
if float64(yVal) < minY {
|
|
minY = float64(yVal)
|
|
}
|
|
}
|
|
|
|
// Now group by baseline
|
|
groups = []lineGroup{} // Reset groups
|
|
for i, yVal := range layout.Y {
|
|
yFloat := float64(yVal)
|
|
lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line
|
|
if lineIdx < 0 {
|
|
lineIdx = 0
|
|
}
|
|
|
|
// Ensure enough groups
|
|
for len(groups) <= lineIdx {
|
|
groups = append(groups, lineGroup{
|
|
y: minY + float64(len(groups))*lineHeight,
|
|
indices: []int{},
|
|
})
|
|
}
|
|
groups[lineIdx].indices = append(groups[lineIdx].indices, i)
|
|
}
|
|
|
|
// If visualLine is out of bounds, clamp
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
|
|
// Clamp to last valid group that has indices
|
|
for i := len(groups) - 1; i >= 0; i-- {
|
|
if len(groups[i].indices) > 0 {
|
|
visualLine = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(groups[visualLine].indices) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
targetGroup := groups[visualLine]
|
|
|
|
// 3. Identify rightmost extent on this line
|
|
rightmostX := 0.0
|
|
rightmostIdx := -1
|
|
for _, i := range targetGroup.indices {
|
|
xEnd := float64(layout.X[i] + layout.Advance[i])
|
|
if xEnd > rightmostX {
|
|
rightmostX = xEnd
|
|
rightmostIdx = i
|
|
}
|
|
}
|
|
|
|
// 4. Check if tap is to the right of the last character
|
|
if rightmostIdx != -1 && x > rightmostX {
|
|
// Position at the end of the line content, before any trailing newline.
|
|
start := base + layout.ByteOffsets[rightmostIdx]
|
|
buf := TheState.Editor.ChunkedBuffer
|
|
var fileContent string
|
|
if buf != nil {
|
|
content, err := buf.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full content: %v", err)
|
|
return 0, false
|
|
}
|
|
fileContent = content
|
|
} else {
|
|
fileContent = TheState.Editor.Buffer
|
|
}
|
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
|
if r == '\n' {
|
|
return start, true
|
|
} else {
|
|
return start + size, true
|
|
}
|
|
}
|
|
|
|
// 5. Otherwise, find the closest glyph on this line.
|
|
bestIdx := -1
|
|
minDist := float64(1e9)
|
|
for _, i := range targetGroup.indices {
|
|
if bestIdx == -1 {
|
|
bestIdx = i
|
|
}
|
|
// Calculate distance to the glyph center
|
|
glyphCenterX := float64(layout.X[i] + layout.Advance[i]/2)
|
|
dist := glyphCenterX - x
|
|
if dist < 0 {
|
|
dist = -dist
|
|
}
|
|
if dist < minDist {
|
|
minDist = dist
|
|
bestIdx = i
|
|
}
|
|
}
|
|
if bestIdx != -1 {
|
|
return base + layout.ByteOffsets[bestIdx], true
|
|
}
|
|
return 0, false
|
|
}
|