Pad/internal/editor/state.go
Greg Pomerantz 6dedffcba7 editor: fix tap-to-position-cursor clamping on large files
Tapping in the editor repositions the cursor, but it was never actually
verified (the old unit test only checked in-bounds/no-panic). On-device
verification found that on a large file scrolled deep, the cursor clamped to
the bottom of the viewport regardless of tap position.

Root cause: the tap handler computed the tap's text-local Y in content space
(pt.Y - region.Y + full ScrollOffset) and passed it to SetCursorFromPoint,
whose visualLine = y/lineHeight then produced a huge content-line number
(e.g. 91,640). But the GlyphLayout is window-relative (layout.Y==0 is the top
of the visible window), so the number far exceeded the window's line count and
clamped to the last group (bottom line). The Phase 3 windowing refactor
introduced the windowed layout without updating the tap handler.

Fix: tapLocalY() converts the tap Y to window-relative space by adding only the
sub-line remainder (ScrollOffset mod lineHeight), never the full scroll.
Extracted as a named helper so it is unit-testable. Added two regression tests
that fail on the pre-fix formula (cursor clamps to the bottom line) and pass on
the fix. Verified on-device: taps now map linearly across the viewport.

Also removed the per-tap/per-glyph log.Printf debug lines in SetCursorFromPoint.
2026-08-16 17:38:24 -04:00

1138 lines
35 KiB
Go

package editor
import (
"fmt"
"log"
"math"
"sort"
"time"
"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
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
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
// editor shows a "too large to edit" notice instead of content (the
// browser can still list the file).
TooLarge bool
TooLargeSize int64
fileVersion map[string]int
lastWriteVersion map[string]int
saveTimer *time.Timer
writeFailed map[string]bool // Added: tracks failed writes for UI
retryAttempts map[string]int // Added: tracks retry attempts
}
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
func (e *EditorState) GetBuffer() string {
if e.ChunkedBuffer != nil {
fullContent, err := e.ChunkedBuffer.FullContent()
if err != nil {
log.Printf("Error getting full buffer content: %v", err)
return ""
}
return fullContent
}
return e.Buffer
}
// IsSaving returns true if a save is pending.
func (e *EditorState) IsSaving() bool {
return e.saveTimer != nil
}
// IsDirty, WriteFailed, and RetryAttempts are computed:
func (e *EditorState) IsDirty() bool {
bufVer := e.fileVersion[e.Filename]
writeVer := e.lastWriteVersion[e.Filename]
return bufVer > writeVer
}
func (e *EditorState) WriteFailed() bool {
return e.writeFailed[e.Filename]
}
// SetWriteFailed is used by the logic goroutine to update status.
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
e.writeFailed[filename] = failed
if !failed {
e.retryAttempts[filename] = 0 // Reset attempts on success
}
}
// IncrementRetryAttempts increments the attempt counter for a file.
func (e *EditorState) IncrementRetryAttempts(filename string) int {
e.retryAttempts[filename]++
return e.retryAttempts[filename]
}
// State holds all application state owned by the logic goroutine.
type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
page Page // current page (Browser or Editor)
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
ByteOffset int // Byte offset of the first visible line
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
// for the viewport. Set by EditorLayout each frame; read by the profiler
// probe to confirm the shaped range stays viewport-bounded (not the file).
VisibleStart int
VisibleEnd int
FocusedElementID string // ID of the currently focused element
Elems []ui.Element
lastEvictionTime time.Time // Throttles chunk eviction
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
// 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) {
// 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
}
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) {
// A too-large file is not editable: ignore all key input.
if TheState.Editor.TooLarge {
return
}
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) {
// 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)
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)
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
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 {
// 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 := cb.LineIndex; li != nil {
totalLines := li.LineCount()
maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2
} else {
// If index is not yet built, allow scrolling beyond estimate.
// Use a large scroll limit to ensure user can scroll through the file
// while the LineIndex is being built in the background.
maxScroll = ui.Dp(1000000) * EditorLineHeight()
}
} else {
// Fallback for full buffer
maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
}
if maxScroll < 0 {
maxScroll = 0
}
TheState.MaxScroll = maxScroll
// Keep the viewport within [0, MaxScroll] even when MaxScroll just shrank
// (the line index finished building, or a shorter file was opened). Without
// this, a ScrollOffset set while MaxScroll was the large pre-index estimate
// would stay past the (now smaller) content and render a blank viewport.
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
if TheState.ScrollOffset > maxScroll {
TheState.ScrollOffset = maxScroll
}
// Compute visible content for virtual scrolling.
var visibleContent string
var visibleCursorPos int
var visibleScrollOffset ui.Dp
var start, end int
cb := TheState.Editor.ChunkedBuffer
if TheState.Editor.TooLarge {
// The file exceeds the edit limit: show a notice instead of content and
// do not edit (the browser can still list the file).
visibleContent = fmt.Sprintf(
"Too large to edit\n\n%s is %s, above the %s limit.\nIt is still listed in the browser.",
TheState.Editor.Filename, formatSize(TheState.Editor.TooLargeSize), formatSize(MaxEditableFileSize))
visibleCursorPos = 0
visibleScrollOffset = 0
} else if cb != nil {
viewportHeight := editorRegion.H
lineHeight := EditorLineHeight()
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
lineHeight = lh
}
start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
// Proactively load chunks needed for the current viewport
startChunk := start / cb.ChunkSize()
endChunk := (end - 1) / cb.ChunkSize()
for i := startChunk; i <= endChunk; i++ {
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
cb.LoadChunkAsync(i)
}
}
// Extract visible content from chunked buffer. Read the full visible
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
// window could leave the lower rows blank).
visibleContent = cb.Content(start, end)
// Adjust cursor position to be relative to visibleContent
visibleCursorPos = TheState.Editor.CursorPosition - start
if visibleCursorPos < 0 {
visibleCursorPos = 0
}
// Adjust scroll offset to be relative to visibleContent origin
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 := cb.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 {
// 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
// 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) {
// Swallow the tap that opened the file. Its gesture belongs to the
// browser row we just left and must not position the cursor in the
// editor (a deliberate tap-to-position happens later, well past this
// window).
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
return
}
if pt, ok := data.(ui.Point); ok {
// Window-space tap -> window-relative text-local coordinates.
// (See tapLocalY: the GlyphLayout is window-relative, so the Y must
// be too — never add the full scroll offset here.)
localX := float64(pt.X - editorRegion.X)
localY := tapLocalY(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}
}
// 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).
func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
return float64(ptY-regionTopY) + math.Mod(float64(scrollOffset), float64(EditorLineHeight()))
}
// 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)
// 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
}
}
// 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
}
if dist < minDist {
minDist = dist
bestIdx = i
}
}
if bestIdx != -1 {
TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx]
}
}