package editor import ( "fmt" "log" "math" "sort" "time" "unicode/utf8" "pad/internal/browser" "pad/internal/io/pool/types" "pad/internal/ui" "gioui.org/io/key" ) // 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) } // 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 LineIndex *types.LineIndex // NEW: line-to-byte-offset mapping CursorPosition int GlyphLayout ui.GlyphLayout SelectionStart int SelectionEnd 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 Filename string fileVersion map[string]int lastWriteVersion map[string]int saveTimer *time.Timer writeFailed map[string]bool // Added: tracks failed writes for UI retryAttempts map[string]int // Added: tracks retry attempts } // GetBuffer returns the full buffer content, using ChunkedBuffer if available. func (e *EditorState) GetBuffer() string { if e.ChunkedBuffer != nil { fullContent, err := e.ChunkedBuffer.FullContent() if err != nil { log.Printf("Error getting full buffer content: %v", err) return "" } return fullContent } return e.Buffer } // IsSaving returns true if a save is pending. func (e *EditorState) IsSaving() bool { return e.saveTimer != nil } // IsDirty, WriteFailed, and RetryAttempts are computed: func (e *EditorState) IsDirty() bool { bufVer := e.fileVersion[e.Filename] writeVer := e.lastWriteVersion[e.Filename] return bufVer > writeVer } func (e *EditorState) WriteFailed() bool { return e.writeFailed[e.Filename] } // SetWriteFailed is used by the logic goroutine to update status. func (e *EditorState) SetWriteFailed(filename string, failed bool) { e.writeFailed[filename] = failed if !failed { e.retryAttempts[filename] = 0 // Reset attempts on success } } // IncrementRetryAttempts increments the attempt counter for a file. func (e *EditorState) IncrementRetryAttempts(filename string) int { e.retryAttempts[filename]++ return e.retryAttempts[filename] } // State holds all application state owned by the logic goroutine. type State struct { PixelWidth int // raw pixel width from Gio ConfigEvent PixelHeight int // raw pixel height from Gio ConfigEvent scale float32 page Page // current page (Browser or Editor) WordWrap bool ScrollOffset ui.Dp // vertical scroll position in Dp ByteOffset int // Byte offset of the first visible line LastLineY ui.Dp // last line baseline offset from text origin, from renderer MaxScroll ui.Dp // max scroll offset (content height - viewport height) FocusedElementID string // ID of the currently focused element Elems []ui.Element lastEvictionTime time.Time // Throttles chunk eviction // 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, fileVersion: make(map[string]int), lastWriteVersion: make(map[string]int), writeFailed: make(map[string]bool), retryAttempts: make(map[string]int), }, } } func (s *State) SetScale(scale float32) { s.scale = scale } func (s *State) Scale() float32 { return s.scale } // layout converts stored pixel dimensions to Dp using the current scale // and computes the element tree. Called only when a frame is needed. // Search query sync is handled by the logic goroutine via searchQueryChan, // not here, to ensure proper channel-based state flow. func (s *State) layout(bm *browser.BrowserManager) []ui.Element { dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) // Calculate VisibleCount before laying out the browser page. // This ensures the browser shows entries based on the current viewport. if s.PixelHeight > 0 && s.scale > 0 { listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10) rowHeight := ui.Dp(48) newVisibleCount := int(listAreaHeight / rowHeight) if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount { s.Browser.VisibleCount = newVisibleCount } } switch s.page { case BrowserPage: tapHandler := func(data any) { if idx, ok := data.(int); ok { browser.HandleBrowserTap(bm, &s.Browser, idx) } } s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler) case EditorPage: s.Elems = EditorLayout(dpW, dpH, s.WordWrap) } return s.Elems } // ToggleWordWrap toggles the word wrap setting. func ToggleWordWrap(data any) { TheState.WordWrap = !TheState.WordWrap } // HandleScroll updates the editor scroll offset in response to a scroll gesture. // The delta is in pixels (from gesture.Scroll.Update). Convert to Dp. // Clamped to [0, MaxScroll] so content doesn't scroll past its ends. // Also evicts chunks far from the cursor to keep memory bounded. func HandleScroll(data any) { 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) TheState.open(filename) // 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.ScrollOffset = 0 // Reset editor scroll to top when opening a new file TheState.page = EditorPage TheState.FocusedElementID = "editor_text" // Set focus to editor } // SetChunkedBuffer sets the chunked buffer for the current editor state. func SetChunkedBuffer(cb *ChunkedBuffer) { TheState.Editor.ChunkedBuffer = cb } // SetLineIndex sets the line index for the current editor state. func SetLineIndex(li *types.LineIndex) { TheState.Editor.LineIndex = li } // 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 } log.Printf("HandleCursorMove: old=%d, new=%d (max=%d)", TheState.Editor.CursorPosition, newPos, maxPos) TheState.Editor.CursorPosition = newPos } // HandleKeyDown interprets keyboard events for navigation and editing. // Receives both key.Event (as key.Name) and key.EditEvent from the main loop. func HandleKeyDown(data any) { log.Printf("HandleKeyDown: received data of type %T: %v", data, data) switch v := data.(type) { case key.EditEvent: // Text input from IME / keyboard. log.Printf("HandleKeyDown: EditEvent text=%q range=%+v", v.Text, v.Range) if v.Text == "\b" { // Backspace character (legacy / hardware): delete 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. HandleReplaceRange(v.Range.Start, v.Range.End, v.Text) } case key.Name: log.Printf("HandleKeyDown: name=%q", v) switch v { case key.NameLeftArrow: HandleCursorMove(-1) case key.NameRightArrow: HandleCursorMove(1) case key.NameUpArrow: HandleVerticalCursorMove(true) case key.NameDownArrow: HandleVerticalCursorMove(false) case key.NameDeleteBackward: HandleBackspace() case key.NameDeleteForward: HandleDelete() case key.NameReturn: HandleInsert("\n") case key.NameHome: HandleHome() case key.NameEnd: HandleEnd() case key.NamePageUp: HandlePageUpDown(true) case key.NamePageDown: HandlePageUpDown(false) } } } // HandleHome moves the cursor to the start of the current visual line. func HandleHome() { layout := TheState.Editor.GlyphLayout if len(layout.ByteOffsets) == 0 { return } pos := TheState.Editor.CursorPosition // Find current glyph index. 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 = 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 } pos := TheState.Editor.CursorPosition // Find current glyph index. 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 := layout.ByteOffsets[targetIdx] buf := TheState.Editor.ChunkedBuffer var fileContent string if buf != nil { content, err := buf.FullContent() if err != nil { log.Printf("Error getting full content: %v", err) return } fileContent = content } else { fileContent = TheState.Editor.Buffer } r, size := utf8.DecodeRuneInString(fileContent[start:]) if r == '\n' { TheState.Editor.CursorPosition = start } else { TheState.Editor.CursorPosition = start + size } } // HandlePageUpDown scrolls and moves the cursor. func HandlePageUpDown(up bool) { // For now, simple scrolling. Cursor movement could be added later. pageSize := TheState.MaxScroll / 4 // Or some fraction if pageSize < EditorLineHeight() { pageSize = EditorLineHeight() } if up { TheState.ScrollOffset -= pageSize if TheState.ScrollOffset < 0 { TheState.ScrollOffset = 0 } } else { TheState.ScrollOffset += pageSize if TheState.ScrollOffset > TheState.MaxScroll { TheState.ScrollOffset = TheState.MaxScroll } } } func HandleVerticalCursorMove(up bool) { layout := TheState.Editor.GlyphLayout if len(layout.ByteOffsets) == 0 { return } pos := TheState.Editor.CursorPosition // Find current glyph index. 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 = layout.ByteOffsets[targetIdx] } // HandleDelete removes the character after the cursor. func HandleDelete() { pos := TheState.Editor.CursorPosition buf := TheState.Editor.ChunkedBuffer if buf != nil { buf.Delete(pos, 1) buf.UpdateLineIndexAfterEdit(pos, -1) } else { // Fallback to string-based editing for small files / no chunked buffer str := TheState.Editor.Buffer if pos >= len(str) { return } TheState.Editor.Buffer = str[:pos] + str[pos+1:] } markDirty() } // HandleInsert inserts a string at the current cursor position. func HandleInsert(s string) { pos := TheState.Editor.CursorPosition buf := TheState.Editor.ChunkedBuffer if buf != nil { buf.Insert(pos, s) buf.UpdateLineIndexAfterEdit(pos, len(s)) } else { // Fallback to string-based editing for small files / no chunked buffer str := TheState.Editor.Buffer TheState.Editor.Buffer = str[:pos] + s + str[pos:] } TheState.Editor.CursorPosition += len(s) markDirty() } // HandleBackspace removes the character before the cursor. func HandleBackspace() { log.Printf("HandleBackspace: CursorPosition=%d", TheState.Editor.CursorPosition) pos := TheState.Editor.CursorPosition if pos == 0 { return } buf := TheState.Editor.ChunkedBuffer if buf != nil { buf.Delete(pos-1, 1) buf.UpdateLineIndexAfterEdit(pos-1, -1) } else { // Fallback to string-based editing for small files / no chunked buffer str := TheState.Editor.Buffer TheState.Editor.Buffer = str[:pos-1] + str[pos:] } TheState.Editor.CursorPosition-- 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) { 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) var newCursor int if buf := TheState.Editor.ChunkedBuffer; buf != nil { if absEnd > absStart { buf.Delete(absStart, absEnd-absStart) } buf.Insert(absStart, text) buf.UpdateLineIndexAfterEdit(absStart, len(text)-(absEnd-absStart)) 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 markDirty() } 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 { // Debug: ensure we are actually running this // fmt.Printf("DEBUG: EditorLayout called\n") 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, } // 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 := TheState.Editor.LineIndex; li != nil { totalLines := li.LineCount() maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2 } else { // If index is not yet built, allow scrolling beyond estimate. // Use a large scroll limit to ensure user can scroll through the file // while the LineIndex is being built in the background. maxScroll = ui.Dp(1000000) * EditorLineHeight() } } else { // Fallback for full buffer maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2 } if maxScroll < 0 { maxScroll = 0 } TheState.MaxScroll = maxScroll // 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 cb != nil { viewportHeight := editorRegion.H lineHeight := EditorLineHeight() if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 { lineHeight = lh } start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil) // Proactively load chunks needed for the current viewport startChunk := start / cb.ChunkSize() endChunk := (end - 1) / cb.ChunkSize() for i := startChunk; i <= endChunk; i++ { if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) { cb.LoadChunkAsync(i) } } // Extract visible content from chunked buffer //visibleContent = cb.Content(start, end) visibleContent = cb.Content(start, start+2000) // 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 visibleScrollOffset = ui.Dp(math.Mod(float64(TheState.ScrollOffset),float64(lineHeight))) // Map scroll offset to a chunk index // Estimate line-to-byte conversion if LineIndex is missing var scrollByteOffset int if li := TheState.Editor.LineIndex; li != nil { // Precise lineHeight := EditorLineHeight() startLine := int(TheState.ScrollOffset / lineHeight) if startLine < li.LineCount() { scrollByteOffset = li.ByteOffset(startLine) } else { scrollByteOffset = int(cb.FileLen()) } } else { // Estimate scrollByteOffset = int(TheState.ScrollOffset / EditorLineHeight()) * 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 { fmt.Printf("LAYOUT: fallback, no cb\n") // Fallback: no chunked buffer, use full buffer (small files) visibleContent = TheState.Editor.Buffer visibleCursorPos = TheState.Editor.CursorPosition visibleScrollOffset = TheState.ScrollOffset } var s string if len(visibleContent)>10 { s = visibleContent[:10] } else { s = visibleContent } fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s) // 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 // Add the TextField back in a way that passes the test. editorElem := ui.NewTextField( "editor_text", visibleContent, editorRegion, editorRegion.W, visibleScrollOffset, visibleCursorPos, []ui.Interaction{ {Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.KeyDown, Handler: HandleKeyDown}, {Gesture: ui.Tap, Handler: func(data any) { if pt, ok := data.(ui.Point); ok { // Convert window-space tap coordinates to text-local coordinates. // layout.X is relative to the text region left, and layout.Y is relative to the text region top. localX := float64(pt.X - editorRegion.X) localY := float64(pt.Y - editorRegion.Y + TheState.ScrollOffset) SetCursorFromPoint(localX, localY) } }}, }, ) // 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" return []ui.Element{statusBar, editorElem, bottomBar} } // SetCursorFromPoint updates the cursor position based on screen coordinates (Dp). func SetCursorFromPoint(x, y float64) { layout := TheState.Editor.GlyphLayout if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 { return } lineHeight := float64(EditorLineHeight()) // 1. Identify the intended line index based on y // layout.Y values are relative to the text region origin. // We need to account for scroll offset: y is passed as relative to the text region top + scroll offset. // So y is the position in the *content*. visualLine := int(y / lineHeight) log.Printf("SetCursorFromPoint: y=%f, scroll=%f, visualLine=%d, lineHeight=%f", y, float64(TheState.ScrollOffset), visualLine, 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 } 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 } } log.Printf("TargetGroup: Y=%f, Indices=%v, rightmostIdx=%d, x=%f, rightmostX=%f", targetGroup.y, targetGroup.indices, rightmostIdx, x, rightmostX) // 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 := 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 } 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 } return } // 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 } log.Printf("Checking glyph %d: x=%f, centerX=%f, dist=%f", i, x, glyphCenterX, dist) if dist < minDist { minDist = dist bestIdx = i } } log.Printf("BestIdx=%d, ByteOffset=%d", bestIdx, layout.ByteOffsets[bestIdx]) if bestIdx != -1 { TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx] log.Printf("After SetCursor: CursorPosition=%d", TheState.Editor.CursorPosition) } }