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 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 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 // 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(EffectiveLineHeight()) + 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(EffectiveLineHeight()) 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 < 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() { // 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)*EffectiveLineHeight() - editorRegion.H + EffectiveLineHeight()/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 } 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, 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. 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 (font-scale // aware), and the same floor decomposition as the window start, so the // tap maps to the drawn geometry for every scroll offset and font // setting. lh := EffectiveLineHeight() 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(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 }