The cut/copy/paste icons were dead placeholders (no handlers); clipboard actions live in the floating selection menu, so the second icon row is removed. The bar goes from two rows at 52dp to one row at 32dp, giving the editor 20dp more height. - filename_test.go now finds the label by type (back icon precedes it) - menu tracking tests updated for the new editorY (reg.Y = 10+32) - scroll_cursor e2e simulation updated to match - On-device verified: back button works, floating menu places above with room and flips below on the first lines, handles unaffected.
2219 lines
71 KiB
Go
2219 lines
71 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/io/key"
|
|
"pad/internal/browser"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// EditorFontSize is the font size used for editor text.
|
|
const EditorFontSize = 14 // unit.Sp
|
|
|
|
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
|
|
const EditorLineHeightScale = 1.2
|
|
|
|
// EditorLineHeight returns the fixed line height in Dp for the editor font.
|
|
func EditorLineHeight() ui.Dp {
|
|
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
|
|
}
|
|
|
|
// formatSize renders a byte count as a human-readable size (e.g. "10.4 MB").
|
|
func formatSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMG"[exp])
|
|
}
|
|
|
|
// Page identifies which page the app is showing.
|
|
type Page int
|
|
|
|
const (
|
|
BrowserPage Page = iota
|
|
EditorPage
|
|
)
|
|
|
|
// SortMode controls how the browser list is sorted.
|
|
type SortMode int
|
|
|
|
const (
|
|
SortByDateDesc SortMode = iota // default: newest first
|
|
SortByDateAsc
|
|
SortByNameAsc
|
|
SortByNameDesc
|
|
)
|
|
|
|
// EditorState holds all editor-specific state.
|
|
type EditorState struct {
|
|
Buffer string // DEPRECATED: use ChunkedBuffer for large files
|
|
ChunkedBuffer *ChunkedBuffer // NEW: chunked file access for virtual scrolling
|
|
CursorPosition int
|
|
GlyphLayout ui.GlyphLayout
|
|
SelectionStart int
|
|
SelectionEnd int
|
|
// SelectionAnchor is the fixed end of a shift-selection; CursorPosition is
|
|
// the active end. -1 means no shift-selection in progress. The effective
|
|
// selection is [min(anchor,cursor), max(anchor,cursor)]; SelectionStart/
|
|
// SelectionEnd are its cached normalized form (-1/-1 = none).
|
|
SelectionAnchor int
|
|
CursorVisible bool
|
|
// IMEWindowStartByte is the absolute byte offset in the buffer where the
|
|
// visible window (IMEWindowText) begins. For small (string) files it is 0
|
|
// and the window is the whole buffer; for large (chunked) files it is the
|
|
// viewport start. The IME snippet is the window, so an EditEvent.Range is
|
|
// relative to the window and must be offset by this to address the buffer.
|
|
IMEWindowStartByte int
|
|
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
|
// It is set during layout and is what an EditEvent.Range indexes into.
|
|
IMEWindowText string
|
|
// EditSeq counts content edits (incremented by markDirty). The shaped
|
|
// glyph layout arriving via layoutChan is only applied to the WrapIndex
|
|
// when its EditSeq matches, so a layout shaped before an edit can never
|
|
// stamp stale wrap counts onto shifted lines.
|
|
EditSeq uint64
|
|
// ShowIMESeq pulses whenever the logic layer wants the soft keyboard up
|
|
// (file open, tap/double-tap on the editor). The renderer issues
|
|
// SoftKeyboardCmd{Show:true} only on a change (see TextField.ShowIMESeq),
|
|
// so a user-dismissed keyboard stays down until the next pulse.
|
|
ShowIMESeq uint64
|
|
// --- Touch selection (v1) ---
|
|
// CaretDrag: after a long press on blank space a single draggable caret
|
|
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
|
|
// floating copy/cut/paste menu (positioned below the line of the selection
|
|
// end; items are recomputed when the menu is shown). SelDragging/
|
|
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
|
|
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
|
|
// handle). All of it is logic-owned; the renderer only reports finger
|
|
// positions and draws the geometry.
|
|
CaretDrag bool
|
|
MenuVisible bool
|
|
MenuRect ui.Region
|
|
MenuItems []ui.MenuItem
|
|
SelDragging bool
|
|
SelDragWhich int
|
|
SelDragRel int
|
|
Filename string
|
|
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
|
// editor shows a "too large to edit" notice instead of content (the
|
|
// browser can still list the file).
|
|
TooLarge bool
|
|
TooLargeSize int64
|
|
fileVersion map[string]int
|
|
lastWriteVersion map[string]int
|
|
saveTimer *time.Timer
|
|
writeFailed map[string]bool // Added: tracks failed writes for UI
|
|
retryAttempts map[string]int // Added: tracks retry attempts
|
|
}
|
|
|
|
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
|
|
func (e *EditorState) GetBuffer() string {
|
|
if e.ChunkedBuffer != nil {
|
|
fullContent, err := e.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full buffer content: %v", err)
|
|
return ""
|
|
}
|
|
return fullContent
|
|
}
|
|
return e.Buffer
|
|
}
|
|
|
|
// IsSaving returns true if a save is pending.
|
|
func (e *EditorState) IsSaving() bool {
|
|
return e.saveTimer != nil
|
|
}
|
|
|
|
// IsDirty, WriteFailed, and RetryAttempts are computed:
|
|
|
|
func (e *EditorState) IsDirty() bool {
|
|
bufVer := e.fileVersion[e.Filename]
|
|
writeVer := e.lastWriteVersion[e.Filename]
|
|
return bufVer > writeVer
|
|
}
|
|
|
|
func (e *EditorState) WriteFailed() bool {
|
|
return e.writeFailed[e.Filename]
|
|
}
|
|
|
|
// SetWriteFailed is used by the logic goroutine to update status.
|
|
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
|
|
e.writeFailed[filename] = failed
|
|
if !failed {
|
|
e.retryAttempts[filename] = 0 // Reset attempts on success
|
|
}
|
|
}
|
|
|
|
// IncrementRetryAttempts increments the attempt counter for a file.
|
|
func (e *EditorState) IncrementRetryAttempts(filename string) int {
|
|
e.retryAttempts[filename]++
|
|
return e.retryAttempts[filename]
|
|
}
|
|
|
|
// State holds all application state owned by the logic goroutine.
|
|
type State struct {
|
|
PixelWidth int // raw pixel width from Gio ConfigEvent
|
|
PixelHeight int // raw pixel height from Gio ConfigEvent
|
|
scale float32
|
|
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
|
|
page Page // current page (Browser or Editor)
|
|
WordWrap bool
|
|
ScrollOffset ui.Dp // vertical scroll position in Dp
|
|
ByteOffset int // Byte offset of the first visible line
|
|
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
|
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
|
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
|
|
// for the viewport. Set by EditorLayout each frame; read by the profiler
|
|
// probe to confirm the shaped range stays viewport-bounded (not the file).
|
|
VisibleStart int
|
|
VisibleEnd int
|
|
// WindowStartLine is the logical line the visible editor window starts
|
|
// at, for the last computed layout (-1 when no editor window is shown).
|
|
// It is shipped with the shaped glyph layout (Frame) so the layout
|
|
// correlation pass applies wrap counts to the lines that layout
|
|
// describes, not the current window (a scroll may have moved it).
|
|
WindowStartLine int
|
|
FocusedElementID string // ID of the currently focused element
|
|
Elems []ui.Element
|
|
lastEvictionTime time.Time // Throttles chunk eviction
|
|
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
|
|
// EditorRegion is the editor text region in app-local Dp, recomputed on
|
|
// every layout. Input handlers (tap, long press, selection drags) convert
|
|
// app-local points to text-local coordinates through it.
|
|
EditorRegion ui.Region
|
|
// Clipboard channels (touch-selection menu). ClipboardSetChan and
|
|
// PasteReqChan are consumed by the main goroutine, which executes the Gio
|
|
// clipboard ops and reports the read result back on PasteChan. Buffered so
|
|
// a harness without a main loop never blocks the logic goroutine.
|
|
clipboardSetChan chan string
|
|
pasteReqChan chan struct{}
|
|
pasteChan chan string
|
|
// Browser state (directly embedded per architecture §8)
|
|
Browser browser.BrowserState // Embedded, not a pointer
|
|
// Editor state
|
|
Editor EditorState // New field
|
|
open func(string)
|
|
}
|
|
|
|
func NewState() *State {
|
|
return &State{
|
|
scale: 1.0,
|
|
page: BrowserPage, // Reverted to BrowserPage
|
|
WordWrap: true, // Enable word wrap by default
|
|
lastEvictionTime: time.Now(),
|
|
Browser: *browser.NewBrowserState(),
|
|
Editor: EditorState{
|
|
CursorPosition: 0,
|
|
SelectionStart: -1,
|
|
SelectionEnd: -1,
|
|
SelectionAnchor: -1,
|
|
fileVersion: make(map[string]int),
|
|
lastWriteVersion: make(map[string]int),
|
|
writeFailed: make(map[string]bool),
|
|
retryAttempts: make(map[string]int),
|
|
},
|
|
clipboardSetChan: make(chan string, 16),
|
|
pasteReqChan: make(chan struct{}, 16),
|
|
pasteChan: make(chan string, 16),
|
|
}
|
|
}
|
|
|
|
func (s *State) SetScale(scale float32) {
|
|
s.scale = scale
|
|
}
|
|
|
|
func (s *State) SetFontScale(fs float32) {
|
|
s.fontScale = fs
|
|
}
|
|
|
|
func (s *State) Scale() float32 {
|
|
return s.scale
|
|
}
|
|
|
|
// stateFontScale returns the user font-size setting (1.0 when unknown).
|
|
func stateFontScale() float32 {
|
|
if TheState != nil && TheState.fontScale > 0 {
|
|
return TheState.fontScale
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// EffectiveLineHeight is the editor line height in density-dp WITH the user
|
|
// font-size setting applied. The shaper draws baselines at
|
|
// Sp(EditorFontSize*LineHeightScale) physical px, which is
|
|
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
|
|
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
|
|
// clamping, cursor vertical move) must use this value rather than the raw
|
|
// EditorLineHeight; at a non-default font setting the two differ by the
|
|
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
|
|
// of lines and make scroll clamping stop short of (or run past) the file
|
|
// ends.
|
|
func EffectiveLineHeight() ui.Dp {
|
|
return EffectiveLineHeightAt(stateFontScale())
|
|
}
|
|
|
|
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
|
|
// (used where the live State value is not the right source, e.g. tests).
|
|
func EffectiveLineHeightAt(fs float32) ui.Dp {
|
|
if fs <= 0 {
|
|
fs = 1
|
|
}
|
|
return ui.Dp(float64(EditorLineHeight()) * float64(fs))
|
|
}
|
|
|
|
// layout converts stored pixel dimensions to Dp using the current scale
|
|
// and computes the element tree. Called only when a frame is needed.
|
|
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
|
// not here, to ensure proper channel-based state flow.
|
|
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
|
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
|
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
|
|
|
// Calculate VisibleCount before laying out the browser page.
|
|
// This ensures the browser shows entries based on the current viewport.
|
|
if s.PixelHeight > 0 && s.scale > 0 {
|
|
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
|
|
rowHeight := ui.Dp(48)
|
|
newVisibleCount := int(listAreaHeight / rowHeight)
|
|
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
|
|
s.Browser.VisibleCount = newVisibleCount
|
|
}
|
|
}
|
|
|
|
switch s.page {
|
|
case BrowserPage:
|
|
tapHandler := func(data any) {
|
|
if idx, ok := data.(int); ok {
|
|
browser.HandleBrowserTap(bm, &s.Browser, idx)
|
|
}
|
|
}
|
|
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
|
|
case EditorPage:
|
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
|
}
|
|
return s.Elems
|
|
}
|
|
|
|
// ToggleWordWrap toggles the word wrap setting.
|
|
func ToggleWordWrap(data any) {
|
|
TheState.WordWrap = !TheState.WordWrap
|
|
}
|
|
|
|
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
|
|
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
|
// Also evicts chunks far from the cursor to keep memory bounded.
|
|
func HandleScroll(data any) {
|
|
// Swallow scroll from the opening gesture: the tap that opened the file can
|
|
// leak a scroll delta into the editor before the line index is built (when
|
|
// MaxScroll is a large estimate), leaving the viewport past the content.
|
|
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
|
return
|
|
}
|
|
delta := data.(int) // pixels
|
|
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
// Evict chunks far from the cursor to keep memory bounded.
|
|
// Only evict when the cursor is near the viewport (i.e., scrolled to top).
|
|
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
|
|
// Temporarily disabled eviction to debug thrashing issues.
|
|
/*
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
|
|
cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
|
|
TheState.lastEvictionTime = time.Now()
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
|
|
func HandleBrowserScroll(data any) {
|
|
delta := data.(int) // pixel delta from gesture.Scroll.Update
|
|
browser.HandlePixelScroll(&TheState.Browser, delta)
|
|
}
|
|
|
|
// GoToBrowser switches the app to the browser page.
|
|
func GoToBrowser(data any) {
|
|
if TheLogic != nil {
|
|
TheLogic.FlushAll()
|
|
}
|
|
TheState.page = BrowserPage
|
|
}
|
|
|
|
// GoToEditor switches the app to the editor page.
|
|
func GoToEditor(data any) {
|
|
TheState.page = EditorPage
|
|
}
|
|
|
|
// OpenFile sets the active filename and switches to the editor page.
|
|
// data is the filename string from the browser list.
|
|
func OpenFile(data any) {
|
|
filename := data.(string)
|
|
// NOTE: the external open hook (TheState.open, e.g. the Android Termux
|
|
// bridge) is intentionally NOT called here. Opening a file is an in-app
|
|
// action; the external bridge was the old tap path and it crashes on
|
|
// Android 7+ (FileUriExposedException from a file:// Intent URI).
|
|
|
|
// Dispatch a request to load the file
|
|
go func() {
|
|
TheLogic.openFileChan <- filename
|
|
}()
|
|
|
|
TheState.Editor.Filename = filename
|
|
TheState.Editor.CursorPosition = 0 // Reset cursor to top
|
|
TheState.Editor.TooLarge = false
|
|
TheState.Editor.TooLargeSize = 0
|
|
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
|
|
TheState.page = EditorPage
|
|
TheState.FocusedElementID = "editor_text" // Set focus to editor
|
|
TheState.Editor.ShowIMESeq++ // raise the keyboard for the newly opened file
|
|
// The tap that opened this file is delivered to the browser row, but its
|
|
// gesture can leak into the now-visible editor and move the cursor/scroll.
|
|
// Record the open time so the editor can swallow taps in a short window
|
|
// right after open (the opening tap, not a deliberate editor tap).
|
|
TheState.justOpenedAt = time.Now()
|
|
}
|
|
|
|
// SetChunkedBuffer sets the chunked buffer for the current editor state.
|
|
func SetChunkedBuffer(cb *ChunkedBuffer) {
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
}
|
|
|
|
// ToggleSortOrder cycles the browser sort mode through four modes.
|
|
func ToggleSortOrder(data any) {
|
|
// Cycle through the 4 sort modes
|
|
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
|
|
// Reset scroll on sort change
|
|
TheState.Browser.ScrollOffset = 0
|
|
// Clear cached pages so they reload with the new sort order
|
|
TheState.Browser.Pages = make(map[int]*browser.Page)
|
|
// Reload initial pages with the new sort order
|
|
browser.LoadInitialPages(&TheState.Browser)
|
|
// Recompute search results if there's an active search query
|
|
browser.HandleSortModeChange(&TheState.Browser)
|
|
}
|
|
|
|
// HandleCursorMove updates the cursor position within bounds.
|
|
func HandleCursorMove(delta int) {
|
|
newPos := TheState.Editor.CursorPosition + delta
|
|
if newPos < 0 {
|
|
newPos = 0
|
|
}
|
|
// Use ChunkedBuffer.FileLen() as the authoritative upper bound
|
|
var maxPos int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
maxPos = int(cb.FileLen())
|
|
}
|
|
if maxPos == 0 {
|
|
// Fallback to Buffer length if ChunkedBuffer not yet initialized
|
|
maxPos = len(TheState.Editor.Buffer)
|
|
}
|
|
if newPos > maxPos {
|
|
newPos = maxPos
|
|
}
|
|
TheState.Editor.CursorPosition = newPos
|
|
}
|
|
|
|
// HandleKeyDown interprets keyboard events for navigation and editing.
|
|
// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with
|
|
// modifier state) or bare key.Name (legacy/test path, no modifiers).
|
|
func HandleKeyDown(data any) {
|
|
// A too-large file is not editable: ignore all key input.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
switch v := data.(type) {
|
|
case key.EditEvent:
|
|
// Text input from IME / keyboard.
|
|
if v.Text == "\b" {
|
|
// Backspace character (legacy / hardware): delete the selection if
|
|
// there is one, else one char before the cursor.
|
|
HandleBackspace()
|
|
} else {
|
|
// IME insert/replace/delete: replace [Range.Start, Range.End) with
|
|
// Text. Range is empty (start==end) for plain inserts; a non-empty
|
|
// range with text is a swipe/autocorrect replacement; a non-empty
|
|
// range with empty text is a range delete. Ignoring Range here is
|
|
// what caused replaced text to be duplicated. A live selection is
|
|
// always replaced (see HandleReplaceRange).
|
|
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
|
|
}
|
|
case ui.KeyEvent:
|
|
handleKey(v.Name, v.Shift)
|
|
case key.Name:
|
|
// Bare key name: no modifier state, so no shift-selection here.
|
|
handleKey(v, false)
|
|
}
|
|
}
|
|
|
|
// handleKey dispatches a key press. shift=true turns cursor moves into
|
|
// selection extensions; edit keys act on the selection when one is active.
|
|
func handleKey(name key.Name, shift bool) {
|
|
switch name {
|
|
case key.NameLeftArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(-1) })
|
|
case key.NameRightArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(1) })
|
|
case key.NameUpArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(true) })
|
|
case key.NameDownArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(false) })
|
|
case key.NameDeleteBackward:
|
|
HandleBackspace()
|
|
case key.NameDeleteForward:
|
|
HandleDelete()
|
|
case key.NameReturn:
|
|
HandleInsert("\n")
|
|
case key.NameHome:
|
|
moveCursor(shift, HandleHome)
|
|
case key.NameEnd:
|
|
moveCursor(shift, HandleEnd)
|
|
case key.NamePageUp:
|
|
moveCursor(shift, func() { HandlePageUpDown(true) })
|
|
case key.NamePageDown:
|
|
moveCursor(shift, func() { HandlePageUpDown(false) })
|
|
}
|
|
}
|
|
|
|
// glyphBase returns the absolute file byte offset at the start of the current
|
|
// visible window. The GlyphLayout is shaped from the visible window alone, so
|
|
// every one of its ByteOffsets is relative to this base; adding it yields an
|
|
// absolute file offset. For a whole-file window (small files, or scroll 0) the
|
|
// base is 0 and this is a no-op.
|
|
func glyphBase() int {
|
|
return TheState.Editor.IMEWindowStartByte
|
|
}
|
|
|
|
// --- Text selection ---------------------------------------------------------
|
|
//
|
|
// The selection is a byte range in absolute file coordinates derived from
|
|
// SelectionAnchor (fixed end) and CursorPosition (active end). No selection:
|
|
// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/
|
|
// end/page extend the selection; a plain cursor move or any edit clears it.
|
|
// All of these must run on the logic goroutine (owner).
|
|
|
|
// selActive reports whether there is a non-empty selection.
|
|
func selActive() bool {
|
|
e := &TheState.Editor
|
|
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
|
|
}
|
|
|
|
// ClearSelection drops the selection and the shift-anchor. It also dismisses
|
|
// the selection menu and cancels any in-progress drag bookkeeping: every path
|
|
// that clears the selection (tap, plain cursor move, any edit) should take
|
|
// the menu and handles down with it.
|
|
func ClearSelection() {
|
|
e := &TheState.Editor
|
|
e.SelectionAnchor = -1
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
e.MenuVisible = false
|
|
e.MenuItems = nil
|
|
e.SelDragging = false
|
|
}
|
|
|
|
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
|
// anchoring at the lower end and placing the cursor (active end) at the upper
|
|
// end. Clamped to the buffer; an empty/clamped range clears instead.
|
|
// Convenience for tests and future gestures (tap-range, double-tap).
|
|
func SetSelection(start, end int) {
|
|
e := &TheState.Editor
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
var fileLen int
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
fileLen = int(cb.FileLen())
|
|
} else {
|
|
fileLen = len(e.Buffer)
|
|
}
|
|
if end > fileLen {
|
|
end = fileLen
|
|
}
|
|
if end <= start {
|
|
ClearSelection()
|
|
e.CursorPosition = start
|
|
return
|
|
}
|
|
e.SelectionAnchor = start
|
|
e.SelectionStart = start
|
|
e.SelectionEnd = end
|
|
e.CursorPosition = end
|
|
}
|
|
|
|
// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor,
|
|
// cursor). A zero-length range (anchor == cursor) is treated as no selection,
|
|
// but the anchor is kept so the next shift-move extends from the original spot
|
|
// again.
|
|
func updateSelectionFromAnchor() {
|
|
e := &TheState.Editor
|
|
if e.SelectionAnchor < 0 {
|
|
ClearSelection()
|
|
return
|
|
}
|
|
a, c := e.SelectionAnchor, e.CursorPosition
|
|
if a == c {
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
return
|
|
}
|
|
if a < c {
|
|
e.SelectionStart, e.SelectionEnd = a, c
|
|
} else {
|
|
e.SelectionStart, e.SelectionEnd = c, a
|
|
}
|
|
}
|
|
|
|
// moveCursor runs op (a cursor-mutating handler). With shift held it keeps
|
|
// the anchor and extends the selection to the new cursor position; without it
|
|
// it clears any selection first. This is the single place where selection
|
|
// bookkeeping meets cursor movement, so every move path (arrows, home/end,
|
|
// page, vertical) gets consistent semantics.
|
|
func moveCursor(shift bool, op func()) {
|
|
if shift {
|
|
if TheState.Editor.SelectionAnchor < 0 {
|
|
TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition
|
|
}
|
|
} else {
|
|
ClearSelection()
|
|
}
|
|
op()
|
|
if shift {
|
|
updateSelectionFromAnchor()
|
|
}
|
|
}
|
|
|
|
// deleteRange removes the byte range [start, end) from the active buffer and
|
|
// updates the line index. Shared by the selection-aware edit handlers.
|
|
func deleteRange(start, end int) {
|
|
if end <= start {
|
|
return
|
|
}
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
buf.Delete(start, end-start)
|
|
buf.UpdateLineIndexAfterDelete(start, end)
|
|
} else {
|
|
str := TheState.Editor.Buffer
|
|
TheState.Editor.Buffer = str[:start] + str[end:]
|
|
}
|
|
}
|
|
|
|
// --- Touch selection (v1) ---------------------------------------------------
|
|
//
|
|
// Android-style touch interaction, layered over the byte-range selection
|
|
// model above. Single tap = caret (existing). Long press = select the word
|
|
// under the finger (or a draggable caret handle on blank space). Double tap
|
|
// = select the word. Handles: drag start/end to resize, drag the body to move
|
|
// the whole selection. Floating menu: Copy / Cut / Paste.
|
|
//
|
|
// Coordinate flow: the renderer reports app-local window Dp points (the same
|
|
// space as a tap). The functions below convert to text-local coordinates via
|
|
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
|
|
// All of these run on the logic goroutine (owner).
|
|
|
|
const (
|
|
menuItemW = ui.Dp(56) // width of one selection-menu button
|
|
menuH = ui.Dp(52) // selection-menu panel height
|
|
)
|
|
|
|
// 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 above the visual line
|
|
// containing the anchor (selection end or caret) — like the native Android
|
|
// selection toolbar — falling back to below when there is no room above.
|
|
// Above matters: the selection handles hang off the line's bottom edge and
|
|
// their 48dp grab boxes would sit under a below-placed menu; the menu is
|
|
// drawn last (top of the z-order) and would steal those touches. 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: the menu 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. It reports false when the anchor is outside the shaped
|
|
// window (scrolled away): there is no layout to anchor to, and the caller
|
|
// hides the menu. Re-anchoring the selection (double-tap) re-shows it.
|
|
func positionSelectionMenu(e *EditorState) bool {
|
|
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 || len(e.MenuItems) == 0 {
|
|
return false
|
|
}
|
|
anchor := e.CursorPosition
|
|
if e.SelectionEnd > anchor {
|
|
anchor = e.SelectionEnd
|
|
}
|
|
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
|
|
if !ok {
|
|
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))
|
|
}
|
|
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
|
|
}
|
|
// Prefer ABOVE the line, like the native Android selection toolbar.
|
|
// Placing it below would sit over the selection handles' grab region
|
|
// (the handles hang off the line's bottom edge), and the menu is drawn
|
|
// last (top of the z-order) so it would steal the touches meant for the
|
|
// handles. Above keeps the handles fully grabbable. Flip below only when
|
|
// there is no room above.
|
|
my := lineTop - float64(menuH) - 8
|
|
if my < 8 {
|
|
my = lineTop + float64(EffectiveLineHeight()) + 8 // flip below the line
|
|
if winH > 0 && my+float64(menuH) > winH-8 {
|
|
my = winH - float64(menuH) - 8 // clamp to window bottom
|
|
}
|
|
}
|
|
if my < 8 {
|
|
my = 8
|
|
}
|
|
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
|
|
return true
|
|
}
|
|
|
|
// showSelectionMenu recomputes the menu items and positions the menu above
|
|
// the line containing the selection end (or caret), falling back to below.
|
|
// Copy and Cut are offered only while a selection is active; Paste always.
|
|
func showSelectionMenu() {
|
|
e := &TheState.Editor
|
|
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
var items []ui.MenuItem
|
|
add := func(icon, label string) {
|
|
items = append(items, ui.MenuItem{
|
|
Icon: icon, Label: label,
|
|
X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH,
|
|
})
|
|
}
|
|
if selActive() {
|
|
add("copy", "Copy")
|
|
add("cut", "Cut")
|
|
}
|
|
add("paste", "Paste")
|
|
e.MenuItems = items
|
|
if positionSelectionMenu(e) {
|
|
e.MenuVisible = true
|
|
}
|
|
}
|
|
|
|
// bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of
|
|
// the glyph at (or just before) the byte, and the top Y of the visual line
|
|
// containing it. ok=false when the layout is empty or the byte is outside
|
|
// the visible window. This is the inverse of textPosFromLocalPoint.
|
|
func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
reg := TheState.EditorRegion
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return 0, 0, false
|
|
}
|
|
base := glyphBase()
|
|
pos := absByte - base
|
|
windowLen := len(TheState.Editor.IMEWindowText)
|
|
if pos < 0 || pos > windowLen {
|
|
return 0, 0, false
|
|
}
|
|
// Same pitch the renderer shaped with (font-scale aware), so the visual
|
|
// line derived from the shaper's baseline matches the drawn geometry.
|
|
lineHeight := float64(EffectiveLineHeight())
|
|
if layout.LineHeight > 0 {
|
|
lineHeight = float64(layout.LineHeight)
|
|
}
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] > pos {
|
|
idx--
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(layout.ByteOffsets) {
|
|
// pos is at/past EOF: anchor on the last glyph.
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
gx := float64(reg.X) + float64(layout.X[idx])
|
|
// layout.Y is the baseline relative to the layout's first line. The
|
|
// shaper places baselines at ascent + lineHeight*lineIndex with
|
|
// 0 < ascent < lineHeight (see drawWrappedText: ascent = FontSize, lineH
|
|
// = FontSize*1.2), so baseline/lineHeight = lineIndex + frac with a frac
|
|
// bounded away from both 0 and 1 — TRUNCATION yields the layout-relative
|
|
// visual line index, robustly (the frac margin is ~0.2*lineHeight, far
|
|
// above float32 noise). Do NOT round to nearest here: with this font's
|
|
// frac ≈ 0.83, +0.5 rounding floors to the line BELOW.
|
|
visualLine := int(float64(layout.Y[idx]) / lineHeight)
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
// The window is drawn shifted up by r' (scrollVisualDecompose). For
|
|
// chunked files the layout is WINDOW-relative (its first line is the
|
|
// window's first line), so visualLine is already relative to the
|
|
// viewport top. For small (string) files the layout covers the WHOLE
|
|
// buffer and the draw shifts by the full scroll offset: subtract the
|
|
// window-start line k so the mapping agrees with the drawn geometry
|
|
// (before this, the menu/caret mapping sat k lines off on scrolled
|
|
// small files).
|
|
k, r := scrollVisualDecompose()
|
|
if TheState.Editor.ChunkedBuffer == nil {
|
|
// May go negative: the anchor is above the viewport. That is the
|
|
// intended off-screen follow-through; callers clamp the resulting
|
|
// position (e.g. the menu pins to the window edge via my < 8).
|
|
visualLine -= k
|
|
}
|
|
lt := float64(reg.Y) - r + float64(visualLine)*lineHeight
|
|
return gx, lt, true
|
|
}
|
|
|
|
// tapInputGuard swallows editor input for a short window after a file opens:
|
|
// the tap that opened the file must not also reposition the caret.
|
|
func tapInputGuard() bool {
|
|
return time.Since(TheState.justOpenedAt) < 300*time.Millisecond
|
|
}
|
|
|
|
// HandleTapAt places the caret at app-local Dp point (x, y). A tap on the
|
|
// visible selection menu is ignored (the menu's own Tap interaction handles
|
|
// it); any other tap dismisses the menu and ends a caret drag.
|
|
func HandleTapAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
e.CaretDrag = false
|
|
hideSelectionMenu()
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
SetCursorFromPoint(localX, localY)
|
|
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
|
|
}
|
|
|
|
// HandleLongPressAt implements Android long-press: on a word it selects the
|
|
// word; on blank space it sets the caret there and shows a single draggable
|
|
// caret handle. The selection menu is shown either way.
|
|
func HandleLongPressAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.TooLarge {
|
|
return
|
|
}
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ws, we, found := wordRangeAt(pos); found {
|
|
SetSelection(ws, we)
|
|
e.CaretDrag = false
|
|
} else {
|
|
ClearSelection()
|
|
e.CursorPosition = pos
|
|
e.CaretDrag = true
|
|
}
|
|
showSelectionMenu()
|
|
}
|
|
|
|
// HandleDoubleTapAt selects the word under the finger (and shows the menu);
|
|
// on blank space it just places the caret.
|
|
func HandleDoubleTapAt(x, y ui.Dp) {
|
|
if tapInputGuard() {
|
|
return
|
|
}
|
|
e := &TheState.Editor
|
|
if e.TooLarge {
|
|
return
|
|
}
|
|
if e.MenuVisible && pointInMenu(x, y) {
|
|
return
|
|
}
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ws, we, found := wordRangeAt(pos); found {
|
|
SetSelection(ws, we)
|
|
e.CaretDrag = false
|
|
showSelectionMenu()
|
|
} else {
|
|
SetCursorFromPoint(localX, localY)
|
|
}
|
|
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
|
|
}
|
|
|
|
// HandleSelDragEvt is the registered SelDrag interaction handler. The
|
|
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
|
|
func HandleSelDragEvt(data any) {
|
|
switch ev := data.(type) {
|
|
case ui.SelectionDragEvent:
|
|
selDragMove(ev.Which, ev.X, ev.Y)
|
|
case ui.SelectionDragEnd:
|
|
TheState.Editor.SelDragging = false
|
|
TheState.Editor.SelDragRel = 0
|
|
// The caret handle (long press on blank space) is a transient affordance;
|
|
// the selection handles come back from the selection state on the next
|
|
// frame, so only the caret mode is dropped here.
|
|
TheState.Editor.CaretDrag = false
|
|
}
|
|
}
|
|
|
|
// selDragMove applies one finger position of a selection/caret drag.
|
|
func selDragMove(which int, x, y ui.Dp) {
|
|
e := &TheState.Editor
|
|
localX := float64(x - TheState.EditorRegion.X)
|
|
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
|
pos, ok := textPosFromLocalPoint(localX, localY)
|
|
if !e.SelDragging {
|
|
e.SelDragging = true
|
|
e.SelDragWhich = which
|
|
if which == 2 && ok {
|
|
// Body drag: the grab fixes the finger's offset from the selection
|
|
// start; the selection itself moves on later events.
|
|
e.SelDragRel = pos - e.SelectionStart
|
|
if e.SelDragRel < 0 {
|
|
e.SelDragRel = 0
|
|
}
|
|
return
|
|
}
|
|
// Start/end/caret handles: the first event already positions the
|
|
// handle (no grab offset needed).
|
|
}
|
|
if !ok {
|
|
return // finger outside the laid-out window: keep last position
|
|
}
|
|
switch e.SelDragWhich {
|
|
case 0: // start handle
|
|
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 < EffectiveLineHeight() {
|
|
pageSize = EffectiveLineHeight()
|
|
}
|
|
|
|
if up {
|
|
TheState.ScrollOffset -= pageSize
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
} else {
|
|
TheState.ScrollOffset += pageSize
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
}
|
|
}
|
|
func HandleVerticalCursorMove(up bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
// If idx == len, we are at the end. Use the last glyph.
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentX := layout.X[idx]
|
|
currentY := layout.Y[idx]
|
|
|
|
var targetIdx int = idx
|
|
|
|
if up {
|
|
// Scan backwards to find the previous line's Y.
|
|
prevY := currentY
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] < prevY {
|
|
prevY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if prevY == currentY {
|
|
// Already at the top line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in prevY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == prevY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Scan forwards to find the next line's Y.
|
|
nextY := currentY
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] > nextY {
|
|
nextY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if nextY == currentY {
|
|
// Already at the bottom line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in nextY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == nextY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// utf8BackspaceWidth returns the byte width of the UTF-8 rune ending at the
|
|
// end of seg (the rune immediately before the cursor). seg must contain the
|
|
// rune's full bytes (4 or more preceding bytes suffice).
|
|
func utf8BackspaceWidth(seg string) int {
|
|
// The rune ends at the end of seg. Scan back over continuation bytes
|
|
// (0x80-0xBF) until the lead byte; the width is the span covered.
|
|
i := len(seg) - 1
|
|
for i > 0 && seg[i]&0xC0 == 0x80 {
|
|
i--
|
|
}
|
|
return len(seg) - i
|
|
}
|
|
|
|
// utf8AdvanceWidth returns the byte width of the UTF-8 rune starting at the
|
|
// beginning of seg (4 or more following bytes suffice). Returns 0 if seg is
|
|
// empty (cursor at EOF).
|
|
func utf8AdvanceWidth(seg string) int {
|
|
if len(seg) == 0 {
|
|
return 0
|
|
}
|
|
w := 1
|
|
for i := 1; i < len(seg) && seg[i]&0xC0 == 0x80; i++ {
|
|
w++
|
|
}
|
|
return w
|
|
}
|
|
|
|
// HandleDelete removes the character (full UTF-8 rune) after the cursor.
|
|
// With a live selection it deletes the whole selection.
|
|
func HandleDelete() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
seg := buf.Content(pos, pos+4)
|
|
if w := utf8AdvanceWidth(seg); w > 0 {
|
|
buf.Delete(pos, w)
|
|
buf.UpdateLineIndexAfterDelete(pos, pos+w)
|
|
markDirty()
|
|
}
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
if pos >= len(str) {
|
|
return
|
|
}
|
|
end := len(str)
|
|
if pos+4 < end {
|
|
end = pos + 4
|
|
}
|
|
TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):]
|
|
markDirty()
|
|
}
|
|
|
|
// HandleInsert inserts a string at the current cursor position. With a live
|
|
// selection it replaces the selection instead.
|
|
func HandleInsert(s string) {
|
|
// EditorState is embedded by value in State: take the address, never a
|
|
// copy, or the writes below are lost.
|
|
e := &TheState.Editor
|
|
pos := e.CursorPosition
|
|
if selActive() {
|
|
pos = e.SelectionStart
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
ClearSelection()
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
buf.Insert(pos, s)
|
|
buf.UpdateLineIndexAfterInsert(pos, s)
|
|
} else {
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := e.Buffer
|
|
e.Buffer = str[:pos] + s + str[pos:]
|
|
}
|
|
e.CursorPosition = pos + len(s)
|
|
markDirty()
|
|
}
|
|
|
|
// HandleBackspace removes the character before the cursor. With a live
|
|
// selection it deletes the whole selection instead.
|
|
func HandleBackspace() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
if pos == 0 {
|
|
return
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(buf.Content(segStart, pos))
|
|
buf.Delete(pos-w, w)
|
|
buf.UpdateLineIndexAfterDelete(pos-w, pos)
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(str[segStart:pos])
|
|
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
}
|
|
|
|
// runeIndexToByteStr returns the byte offset of the n-th rune (0-indexed) in
|
|
// s. A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte lead byte
|
|
// (>=0xC0); 0x80-0xBF are continuation bytes. If n is past the end, returns
|
|
// len(s).
|
|
func runeIndexToByteStr(s string, n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
runes := 0
|
|
for i := 0; i < len(s); i++ {
|
|
b := s[i]
|
|
if b < 0x80 || b >= 0xC0 {
|
|
if runes == n {
|
|
return i
|
|
}
|
|
runes++
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
|
|
// with text and places the cursor at the end of the inserted text.
|
|
//
|
|
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
|
|
// or autocorrect commit replaces the selected region instead of blindly
|
|
// inserting at the cursor, so the old text is removed (no duplication). It
|
|
// also covers plain inserts (start==end) and range deletes (text == "").
|
|
//
|
|
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
|
|
// is byte-based, so they are converted to byte offsets first. Must be called
|
|
// on the logic goroutine (owner).
|
|
func HandleReplaceRange(startRune, endRune int, text string) {
|
|
// A too-large file is not editable: ignore IME commits.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
if startRune > endRune {
|
|
startRune, endRune = endRune, startRune
|
|
}
|
|
// The IME snippet is the visible window (IMEWindowText), so startRune/
|
|
// endRune are relative to that window. Resolve them to window-byte
|
|
// offsets, then add the window's absolute start to address the buffer.
|
|
// For small (string) files the window is the whole buffer (start 0), so
|
|
// this reduces to absolute addressing. If IMEWindowText is empty (tests
|
|
// that never run layout), fall back to the whole buffer as the window.
|
|
windowStart := TheState.Editor.IMEWindowStartByte
|
|
windowText := TheState.Editor.IMEWindowText
|
|
if windowText == "" {
|
|
windowStart = 0
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
windowText, _ = cb.FullContent()
|
|
} else {
|
|
windowText = TheState.Editor.Buffer
|
|
}
|
|
}
|
|
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
|
|
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
|
|
// A live selection is always replaced by the commit: union the IME range
|
|
// with the selection so the outcome is deterministic no matter what the
|
|
// IME reports (some IMEs send the full selection range, others send an
|
|
// empty range at the caret expecting the app to consume its reported
|
|
// selection).
|
|
if selActive() {
|
|
if absStart > TheState.Editor.SelectionStart {
|
|
absStart = TheState.Editor.SelectionStart
|
|
}
|
|
if absEnd < TheState.Editor.SelectionEnd {
|
|
absEnd = TheState.Editor.SelectionEnd
|
|
}
|
|
}
|
|
if imeDebugLog {
|
|
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
|
|
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
|
|
}
|
|
|
|
var newCursor int
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
if absEnd > absStart {
|
|
buf.Delete(absStart, absEnd-absStart)
|
|
}
|
|
buf.Insert(absStart, text)
|
|
if absEnd > absStart {
|
|
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
|
|
}
|
|
buf.UpdateLineIndexAfterInsert(absStart, text)
|
|
newCursor = absStart + len(text)
|
|
} else {
|
|
s := TheState.Editor.Buffer
|
|
if absEnd > absStart {
|
|
s = s[:absStart] + s[absEnd:]
|
|
}
|
|
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
|
|
newCursor = absStart + len(text)
|
|
}
|
|
TheState.Editor.CursorPosition = newCursor
|
|
// A commit consumed any selection it overlapped (see the union above).
|
|
ClearSelection()
|
|
if imeDebugLog {
|
|
dbgBuf := currentEditorText()
|
|
if len(dbgBuf) > 40 {
|
|
dbgBuf = dbgBuf[:40]
|
|
}
|
|
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
|
|
}
|
|
markDirty()
|
|
}
|
|
|
|
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
|
|
// use; enable when debugging IME commit/cursor sync on device.
|
|
const imeDebugLog = false
|
|
|
|
// currentEditorText returns the current editor buffer contents (full for
|
|
// chunked files). Used only for debug logging.
|
|
func currentEditorText() string {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
s, _ := cb.FullContent()
|
|
return s
|
|
}
|
|
return TheState.Editor.Buffer
|
|
}
|
|
|
|
func markDirty() {
|
|
// Every content edit funnels through here, so EditSeq is the universal
|
|
// "content changed" token (the WrapIndex layout-correlation gate uses it).
|
|
TheState.Editor.EditSeq++
|
|
// TheLogic is nil in pure unit tests (no logic goroutine). Editing the
|
|
// buffer is still valid there; only the autosave side-effect is skipped.
|
|
if TheLogic != nil {
|
|
TheLogic.markDirty()
|
|
}
|
|
}
|
|
|
|
// EditorLayout computes the element tree for the editor page.
|
|
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|
margin := ui.Dp(10)
|
|
|
|
// --- Top bar: one row, back icon + filename. Cut/copy/paste live in the
|
|
// floating selection menu (the native Android pattern); the dead icon row
|
|
// is gone, saving 20dp of editor height. ---
|
|
statusBarRegion := ui.Region{
|
|
X: margin, Y: margin,
|
|
W: screenWidth - margin*2,
|
|
H: ui.Dp(32),
|
|
}
|
|
statusBarW := statusBarRegion.W
|
|
filename := TheState.Editor.Filename
|
|
if filename == "" {
|
|
filename = "untitled.txt"
|
|
}
|
|
statusBar := ui.NewContainer(
|
|
statusBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
|
|
ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32), Y: ui.Dp(6), W: statusBarW - ui.Dp(32), H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
},
|
|
)
|
|
|
|
// --- Bottom bar ---
|
|
bottomBarHeight := ui.BottomBarHeight
|
|
bottomBarY := screenHeight - margin - bottomBarHeight
|
|
bottomBarRegion := ui.Region{
|
|
X: margin, Y: bottomBarY,
|
|
W: screenWidth - margin*2,
|
|
H: bottomBarHeight,
|
|
}
|
|
bottomBarW := bottomBarRegion.W
|
|
wrapText := "Wrap: Off"
|
|
if wordWrap {
|
|
wrapText = "Wrap: On"
|
|
}
|
|
statusText := "Saved"
|
|
if TheState.Editor.IsSaving() {
|
|
statusText = "Saving..."
|
|
} else if TheState.Editor.WriteFailed() {
|
|
statusText = "Error"
|
|
} else if TheState.Editor.IsDirty() {
|
|
statusText = "Modified"
|
|
}
|
|
|
|
// Compute cursor position and file size for the bottom bar.
|
|
cursorPos := TheState.Editor.CursorPosition
|
|
var fileSize int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
fileSize = int(cb.FileLen())
|
|
} else {
|
|
fileSize = len(TheState.Editor.Buffer)
|
|
}
|
|
cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize)
|
|
|
|
bottomBar := ui.NewContainer(
|
|
bottomBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
|
|
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
|
|
{Gesture: ui.Tap, Handler: ToggleWordWrap},
|
|
}),
|
|
},
|
|
)
|
|
|
|
// --- Editor text area ---
|
|
editorY := statusBarRegion.Y + statusBarRegion.H
|
|
editorH := bottomBarRegion.Y - editorY
|
|
editorRegion := ui.Region{
|
|
X: margin, Y: editorY,
|
|
W: screenWidth - margin*2,
|
|
H: editorH,
|
|
}
|
|
// Stored for the input handlers (tap / long press / drags), which convert
|
|
// app-local Dp points to text-local coordinates through it.
|
|
TheState.EditorRegion = editorRegion
|
|
// WindowStartLine is the logical line the visible window starts at; it is
|
|
// shipped with the shaped layout (Frame) so the layout-correlation pass in
|
|
// the logic goroutine knows which lines the layout describes. -1 outside
|
|
// the editor window (browser page, too-large notice).
|
|
TheState.WindowStartLine = -1
|
|
// Compute max scroll offset from the last line baseline reported by the renderer.
|
|
// lastLineY is the shaper's Y value for the last line's baseline.
|
|
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
|
|
var maxScroll ui.Dp
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if li := cb.LineIndex; li != nil {
|
|
// Document height is measured in VISUAL lines: a wrapped logical
|
|
// line occupies several of them, so the max scroll must use the
|
|
// WrapIndex total, not the logical line count. Before shaping,
|
|
// every line estimates to one visual line, so MaxScroll starts at
|
|
// the no-wrap value and grows as shaped counts arrive — it only
|
|
// ever grows during a warm-up, never jumps under the viewport.
|
|
total := int64(li.LineCount())
|
|
if w := cb.WrapIndex; w != nil {
|
|
total = int64(w.TotalVisuals())
|
|
}
|
|
lineHeight := EffectiveLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
maxScroll = ui.Dp(float64(total)*float64(lineHeight)) - editorRegion.H + lineHeight/2
|
|
} else {
|
|
// If index is not yet built, allow scrolling beyond estimate.
|
|
// Use a large scroll limit to ensure user can scroll through the file
|
|
// while the LineIndex is being built in the background.
|
|
maxScroll = ui.Dp(1000000) * EffectiveLineHeight()
|
|
}
|
|
} else {
|
|
// Fallback for full buffer
|
|
maxScroll = TheState.LastLineY - editorRegion.H + EffectiveLineHeight()/2
|
|
}
|
|
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
TheState.MaxScroll = maxScroll
|
|
// Keep the viewport within [0, MaxScroll] even when MaxScroll just shrank
|
|
// (the line index finished building, or a shorter file was opened). Without
|
|
// this, a ScrollOffset set while MaxScroll was the large pre-index estimate
|
|
// would stay past the (now smaller) content and render a blank viewport.
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > maxScroll {
|
|
TheState.ScrollOffset = maxScroll
|
|
}
|
|
|
|
// Compute visible content for virtual scrolling.
|
|
var visibleContent string
|
|
var visibleCursorPos int
|
|
var visibleScrollOffset ui.Dp
|
|
var start, end int
|
|
|
|
cb := TheState.Editor.ChunkedBuffer
|
|
if TheState.Editor.TooLarge {
|
|
// The file exceeds the edit limit: show a notice instead of content and
|
|
// do not edit (the browser can still list the file).
|
|
visibleContent = fmt.Sprintf(
|
|
"Too large to edit\n\n%s is %s, above the %s limit.\nIt is still listed in the browser.",
|
|
TheState.Editor.Filename, formatSize(TheState.Editor.TooLargeSize), formatSize(MaxEditableFileSize))
|
|
visibleCursorPos = 0
|
|
visibleScrollOffset = 0
|
|
} else if cb != nil {
|
|
viewportHeight := editorRegion.H
|
|
lineHeight := EffectiveLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
// Assign (not :=) into the outer start/end: they are read below the
|
|
// block to derive IMEWindowStartByte and the window-relative
|
|
// selection. A `:=` here would shadow them (winLine is new) and the
|
|
// outer pair would stay 0 — every scrolled frame would then map the
|
|
// selection against byte 0 (the selection "jumps" with the scroll) and
|
|
// IME commits would land at the wrong buffer position.
|
|
var winLine int
|
|
start, end, winLine = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
|
// Ship with the frame: the shaped layout's wrap counts belong to THIS
|
|
// window's lines (a scroll may move the window before the layout
|
|
// arrives, but an edit invalidates it — see EditorState.EditSeq).
|
|
TheState.WindowStartLine = winLine
|
|
|
|
// Proactively load chunks needed for the current viewport
|
|
startChunk := start / cb.ChunkSize()
|
|
endChunk := (end - 1) / cb.ChunkSize()
|
|
for i := startChunk; i <= endChunk; i++ {
|
|
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
|
|
cb.LoadChunkAsync(i)
|
|
}
|
|
}
|
|
|
|
// Extract visible content from chunked buffer. Read the full visible
|
|
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
|
|
// window could leave the lower rows blank).
|
|
visibleContent = cb.Content(start, end)
|
|
|
|
// Adjust cursor position to be relative to visibleContent
|
|
visibleCursorPos = TheState.Editor.CursorPosition - start
|
|
if visibleCursorPos < 0 {
|
|
visibleCursorPos = 0
|
|
}
|
|
|
|
// Adjust scroll offset to be relative to visibleContent origin
|
|
// Sub-line shift for the renderer: the SAME visual-line decomposition
|
|
// the window start used (VisibleByteRange above), so the drawn
|
|
// geometry and the window's content lines agree for every scroll
|
|
// offset and wrap state. scrollVisualDecompose re-derives it from the
|
|
// same inputs; the two agree by construction (see its doc).
|
|
_, subLine := scrollVisualDecompose()
|
|
visibleScrollOffset = ui.Dp(subLine)
|
|
|
|
// Map scroll offset to a chunk index
|
|
// Estimate line-to-byte conversion if LineIndex is missing
|
|
var scrollByteOffset int
|
|
if li := cb.LineIndex; li != nil {
|
|
// Precise: same decomposition as the window start above.
|
|
prefetchLine := winLine
|
|
if prefetchLine < li.LineCount() {
|
|
scrollByteOffset = li.ByteOffset(prefetchLine)
|
|
} else {
|
|
scrollByteOffset = int(cb.FileLen())
|
|
}
|
|
} else {
|
|
// Estimate
|
|
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EffectiveLineHeight())
|
|
scrollByteOffset = prefetchLine * 50
|
|
}
|
|
|
|
scrollChunk := scrollByteOffset / cb.ChunkSize()
|
|
// Use a smaller radius to prevent overloading the worker pool.
|
|
// Content() will load chunks immediately needed by the viewport.
|
|
cb.Prefetch(scrollChunk, 1)
|
|
} else {
|
|
// Fallback: no chunked buffer, use full buffer (small files)
|
|
visibleContent = TheState.Editor.Buffer
|
|
visibleCursorPos = TheState.Editor.CursorPosition
|
|
visibleScrollOffset = TheState.ScrollOffset
|
|
}
|
|
|
|
// Record the shaped visible range for the profiler probe (confirms the
|
|
// virtual-scroll window stays viewport-bounded, not the whole file).
|
|
TheState.VisibleStart = start
|
|
TheState.VisibleEnd = end
|
|
|
|
// Record the visible window for IME: the snippet is this window, so an
|
|
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
|
// to address the buffer. start is 0 for small (string) files, so the
|
|
// window is the whole buffer there.
|
|
TheState.Editor.IMEWindowStartByte = start
|
|
TheState.Editor.IMEWindowText = visibleContent
|
|
// Window-relative selection for the TextField (byte offsets into
|
|
// visibleContent); -1 means nothing visible is selected. The IME push and
|
|
// the in-app highlight consume this; the logic side keeps absolute
|
|
// offsets in EditorState.
|
|
windowSelStart, windowSelEnd := -1, -1
|
|
if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss {
|
|
ws, we := ss-start, TheState.Editor.SelectionEnd-start
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
if we > len(visibleContent) {
|
|
we = len(visibleContent)
|
|
}
|
|
if ws < we {
|
|
windowSelStart, windowSelEnd = ws, we
|
|
}
|
|
}
|
|
|
|
// Add the TextField back in a way that passes the test.
|
|
editorElem := ui.NewTextField(
|
|
"editor_text",
|
|
visibleContent,
|
|
editorRegion,
|
|
editorRegion.W,
|
|
visibleScrollOffset,
|
|
visibleCursorPos,
|
|
windowSelStart,
|
|
windowSelEnd,
|
|
[]ui.Interaction{
|
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
|
// The Tap interaction also carries the long-press and double-tap
|
|
// events the renderer derives from the same gesture.Click (they share
|
|
// the editor's click region). The renderer converts all of them to
|
|
// text-local coordinates through EditorRegion + tapLocalY.
|
|
{Gesture: ui.Tap, Handler: func(data any) {
|
|
// The just-opened guard (swallowing the tap that opened the file)
|
|
// lives inside the Handle* functions.
|
|
switch pt := data.(type) {
|
|
case ui.Point:
|
|
HandleTapAt(pt.X, pt.Y)
|
|
case ui.DoubleTapPoint:
|
|
HandleDoubleTapAt(pt.X, pt.Y)
|
|
case ui.LongPressPoint:
|
|
HandleLongPressAt(pt.X, pt.Y)
|
|
}
|
|
}},
|
|
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
|
},
|
|
)
|
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
|
// for Gio to deliver key events to this element. ShowIMESeq carries the
|
|
// keyboard-raise pulse (see TextField.ShowIMESeq).
|
|
editorElem.ShowIMESeq = TheState.Editor.ShowIMESeq
|
|
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
|
// Caret handle visibility (long press on blank space).
|
|
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
|
|
|
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
|
// The selection menu is added last so it draws on top of the editor.
|
|
// Re-anchor it to the selection's live screen position every frame so it
|
|
// follows the text while scrolling (it used to stay where it was first
|
|
// shown, detaching from the selection).
|
|
if TheState.Editor.MenuVisible {
|
|
if !positionSelectionMenu(&TheState.Editor) {
|
|
hideSelectionMenu()
|
|
}
|
|
}
|
|
if TheState.Editor.MenuVisible {
|
|
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
|
|
if pt, ok := data.(ui.Point); ok {
|
|
HandleMenuTap(pt.X, pt.Y)
|
|
}
|
|
}))
|
|
}
|
|
return elems
|
|
}
|
|
|
|
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
|
|
// window-relative coordinate space of the GlyphLayout, where layout.Y==0 is the
|
|
// top of the VISIBLE WINDOW, not the top of the file. It adds only the sub-line
|
|
// remainder of the scroll (ScrollOffset mod lineHeight), never the full scroll:
|
|
// the full scroll would make visualLine a huge content-line number far past the
|
|
// window's line count, clamping the cursor to the bottom line of the viewport on
|
|
// any large file (it only worked by luck on small files whose window spanned the
|
|
// tapped content-line number).
|
|
// scrollDecompose splits a scroll offset s into content line k and sub-line
|
|
// remainder r such that k*lh <= s < (k+1)*lh — the floor decomposition in the
|
|
// Dp domain, computed in float64.
|
|
//
|
|
// k and r must be the SINGLE shared source of both the window start line
|
|
// (visibleByteRangePrecise/Estimate) and the sub-line draw/tap remainder
|
|
// (visibleScrollOffset in layoutFrame, tapLocalY). Computing k as
|
|
// int(s/lh) in the Dp float32 domain can round the quotient UP across an
|
|
// integer boundary while the (float64) remainder still reflects the line
|
|
// below; the two bookkeeping values then disagree by one line in a
|
|
// sub-pixel-wide band of scroll offsets, shifting the rendered window — and
|
|
// with it every tapped content line — by one. The float64 decomposition with
|
|
// the r<0 / r>=lh corrections keeps k and r consistent for every s >= 0.
|
|
// scrollVisualDecompose maps the current scroll offset to (windowStartLine,
|
|
// drawOffset) in visual-line space — the exact inverse of what the renderer
|
|
// does when it draws the window at reg.Y - drawOffset.
|
|
//
|
|
// v0 is the visual line at the viewport top; k is the logical line that
|
|
// contains it (via the WrapIndex); drawOffset = ScrollOffset - V(k)*lh, the
|
|
// amount the renderer shifts the window up. With word wrap, drawOffset may
|
|
// exceed one lineHeight: the viewport top then sits inside a wrapped line's
|
|
// continuation, and the wrapped lines that fall above the viewport are
|
|
// clipped away. Without a WrapIndex (index not built yet) or with an
|
|
// all-ones index (nothing shaped/wrapped yet), the result is exactly the
|
|
// legacy mapping (k = v0, drawOffset = ScrollOffset mod lh).
|
|
//
|
|
// Must be called on the logic goroutine (reads ScrollOffset, the last
|
|
// GlyphLayout and the buffer's WrapIndex; writes nothing).
|
|
func scrollVisualDecompose() (k int, r float64) {
|
|
lh := EffectiveLineHeight()
|
|
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
|
|
lh = gl.LineHeight
|
|
}
|
|
v0, r0 := scrollDecompose(TheState.ScrollOffset, lh)
|
|
w := (*WrapIndex)(nil)
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
w = cb.WrapIndex
|
|
}
|
|
if w == nil {
|
|
return v0, r0
|
|
}
|
|
k = w.LineForVisual(int32(v0))
|
|
vk := w.VisualsBefore(k)
|
|
// r = s - V(k)*lh = (v0 - V(k))*lh + r0: the v0 - V(k) term counts the
|
|
// wrapped continuation lines above the window start.
|
|
return k, float64(v0-int(vk))*float64(lh) + r0
|
|
}
|
|
|
|
// applyWrapCounts corrects the WrapIndex counts for the lines described by
|
|
// a shaped layout. The layout's VisualLineStarts (window-relative byte
|
|
// offsets, one per visual line start) are grouped by the logical line whose
|
|
// byte range contains each start; a wrapped logical line then carries its
|
|
// true visual-line count.
|
|
//
|
|
// The layout describes the window it was shaped for (fb.WindowStartLine /
|
|
// fb.WindowStartByte), which may differ from the CURRENT window (a scroll
|
|
// can move the window between shaping and delivery) — that is fine: the
|
|
// counts belong to real lines that are still valid as long as no edit has
|
|
// shifted them, which the caller checks via EditSeq before calling here.
|
|
//
|
|
// A logical line whose range contains no visual line start (an empty line,
|
|
// or a line the shaper produced no starts for) keeps its current count.
|
|
//
|
|
// Must be called on the logic goroutine.
|
|
func (s *State) applyWrapCounts(fb ui.LayoutFeedback) {
|
|
cb := s.Editor.ChunkedBuffer
|
|
if cb == nil || cb.WrapIndex == nil || fb.WindowStartLine < 0 {
|
|
return
|
|
}
|
|
// fb.WindowText is the exact text this layout was shaped for (carried in
|
|
// the frame). Grouping over the CURRENT window (IMEWindowText) instead
|
|
// would attribute counts to the wrong lines whenever a scroll moved the
|
|
// window between shaping and delivery.
|
|
winText := fb.WindowText
|
|
if winText == "" {
|
|
return
|
|
}
|
|
starts := fb.GlyphLayout.VisualLineStarts
|
|
if len(starts) == 0 {
|
|
return
|
|
}
|
|
// Walk the window's logical lines (delimited by '\n'); attribute each
|
|
// visual line start to the logical line containing it. Both the starts
|
|
// and the line ranges are ascending, so a single forward pointer works.
|
|
si := 0
|
|
lineStart := 0
|
|
for li := 0; ; li++ {
|
|
idx := strings.IndexByte(winText[lineStart:], '\n')
|
|
lineEnd := len(winText)
|
|
last := false
|
|
if idx >= 0 {
|
|
lineEnd = lineStart + idx + 1
|
|
} else {
|
|
last = true
|
|
}
|
|
count := 0
|
|
for si < len(starts) && int(starts[si]) < lineEnd {
|
|
if int(starts[si]) >= lineStart {
|
|
count++
|
|
}
|
|
si++
|
|
}
|
|
if count > 0 {
|
|
cb.WrapIndex.Set(fb.WindowStartLine+li, int32(count))
|
|
}
|
|
if last {
|
|
break
|
|
}
|
|
lineStart = lineEnd
|
|
}
|
|
}
|
|
|
|
func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
|
|
sf, lf := float64(s), float64(lh)
|
|
if lf <= 0 {
|
|
return 0, 0
|
|
}
|
|
k = int(sf / lf)
|
|
r = sf - float64(k)*lf
|
|
if r < 0 {
|
|
k--
|
|
r = sf - float64(k)*lf
|
|
}
|
|
if r >= lf {
|
|
k++
|
|
r = sf - float64(k)*lf
|
|
}
|
|
if k < 0 {
|
|
k, r = 0, sf
|
|
}
|
|
return k, r
|
|
}
|
|
|
|
// tapLocalY returns the text-local Y (Dp, window-relative: 0 = top of the
|
|
// visible window, matching GlyphLayout.Y) of an app-local tap at ptY.
|
|
//
|
|
// The renderer draws the window's top at reg.Y - r' (r' = the sub-line draw
|
|
// offset from scrollVisualDecompose), so a point at app-Y ptY sits at
|
|
// window-Y (ptY - regionTop) + r'. r' is the same value the renderer was
|
|
// given, so the tap maps to the drawn geometry for every scroll offset,
|
|
// font setting and wrap state — including the case where the viewport top
|
|
// sits inside a wrapped line's continuation (r' > one line).
|
|
func tapLocalY(ptY, regionTopY ui.Dp) float64 {
|
|
_, r := scrollVisualDecompose()
|
|
return float64(ptY-regionTopY) + r
|
|
}
|
|
|
|
// SetCursorFromPoint updates the cursor position based on text-local
|
|
// coordinates (Dp, window-relative: x from the text region's left edge, y as
|
|
// produced by tapLocalY). A tap is an explicit cursor placement: it always
|
|
// clears any selection.
|
|
func SetCursorFromPoint(x, y float64) {
|
|
ClearSelection()
|
|
if pos, ok := textPosFromLocalPoint(x, y); ok {
|
|
TheState.Editor.CursorPosition = pos
|
|
}
|
|
}
|
|
|
|
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
|
|
// an absolute byte offset in the buffer. ok=false when the layout is empty or
|
|
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
|
|
// selection-drag handlers.
|
|
func textPosFromLocalPoint(x, y float64) (int, bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
|
return 0, false
|
|
}
|
|
|
|
base := glyphBase()
|
|
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)
|
|
|
|
// 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
|
|
}
|