Pad/internal/editor/state.go
Greg Pomerantz 3bc3ed3530 editor: add window base offset to tap/cursor positioning
The GlyphLayout is shaped from the visible window alone, so its
ByteOffsets are relative to the window start (IMEWindowStartByte). But
SetCursorFromPoint, HandleHome, HandleEnd and HandleVerticalCursorMove
treated them as absolute file offsets. On a large file scrolled deep, the
cursor was set to a window-relative offset (near the file start), so the
next EditorLayout computed visibleCursorPos = CursorPosition - start as a
small/negative value and the IME selection snapped to the window top
(regardless of where the user tapped).

Add glyphBase() and convert at the four cursor boundaries: subtract the
base before searching the window-relative offsets, add it back before
storing the absolute CursorPosition. For small files (window == whole
file) the base is 0 and the change is a no-op.

Add TestTapToPosition_WindowBaseOffset, a regression test that sets a
non-zero IMEWindowStartByte and asserts the cursor lands at base + window
byte (fails on the pre-fix window-relative assignment).

Verified on-device: with a 6 MB file scrolled to ~line 1200, a tap now
places the cursor on the tapped line (typed text lands ~16 lines below the
window top), where before it always landed on the window top.
2026-08-16 20:59:06 -04:00

1153 lines
36 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
}
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
}
switch v := data.(type) {
case key.EditEvent:
// Text input from IME / keyboard.
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:
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)
}
}
}
// 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
}
// HandleHome moves the cursor to the start of the current visual line.
func HandleHome() {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentY := layout.Y[idx]
// Find first glyph on currentY.
targetIdx := idx
for i := idx; i >= 0; i-- {
if layout.Y[i] == currentY {
targetIdx = i
} else {
break
}
}
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
}
// HandleEnd moves the cursor to the end of the current visual line.
func HandleEnd() {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentY := layout.Y[idx]
// Find last glyph on currentY.
targetIdx := idx
for i := idx; i < len(layout.Y); i++ {
if layout.Y[i] == currentY {
targetIdx = i
} else {
break
}
}
// Position after the last character of the line.
// If it's a newline, it's the newline itself.
// start is window-relative; convert to an absolute file offset.
start := base + layout.ByteOffsets[targetIdx]
buf := TheState.Editor.ChunkedBuffer
var fileContent string
if buf != nil {
content, err := buf.FullContent()
if err != nil {
log.Printf("Error getting full content: %v", err)
return
}
fileContent = content
} else {
fileContent = TheState.Editor.Buffer
}
r, size := utf8.DecodeRuneInString(fileContent[start:])
if r == '\n' {
TheState.Editor.CursorPosition = start
} else {
TheState.Editor.CursorPosition = start + size
}
}
// HandlePageUpDown scrolls and moves the cursor.
func HandlePageUpDown(up bool) {
// For now, simple scrolling. Cursor movement could be added later.
pageSize := TheState.MaxScroll / 4 // Or some fraction
if pageSize < EditorLineHeight() {
pageSize = EditorLineHeight()
}
if up {
TheState.ScrollOffset -= pageSize
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
} else {
TheState.ScrollOffset += pageSize
if TheState.ScrollOffset > TheState.MaxScroll {
TheState.ScrollOffset = TheState.MaxScroll
}
}
}
func HandleVerticalCursorMove(up bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
base := glyphBase()
pos := TheState.Editor.CursorPosition - base
if pos < 0 {
pos = 0
}
// Find current glyph index (offsets are window-relative).
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
// If idx == len, we are at the end. Use the last glyph.
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentX := layout.X[idx]
currentY := layout.Y[idx]
var targetIdx int = idx
if up {
// Scan backwards to find the previous line's Y.
prevY := currentY
for i := idx; i >= 0; i-- {
if layout.Y[i] < prevY {
prevY = layout.Y[i]
break
}
}
if prevY == currentY {
// Already at the top line?
return
}
// Now find the closest X in prevY.
bestDiff := float32(1e9)
for i := 0; i < len(layout.Y); i++ {
if layout.Y[i] == prevY {
diff := float32(layout.X[i] - currentX)
if diff < 0 {
diff = -diff
}
if diff < bestDiff {
bestDiff = diff
targetIdx = i
}
}
}
} else {
// Scan forwards to find the next line's Y.
nextY := currentY
for i := idx; i < len(layout.Y); i++ {
if layout.Y[i] > nextY {
nextY = layout.Y[i]
break
}
}
if nextY == currentY {
// Already at the bottom line?
return
}
// Now find the closest X in nextY.
bestDiff := float32(1e9)
for i := 0; i < len(layout.Y); i++ {
if layout.Y[i] == nextY {
diff := float32(layout.X[i] - currentX)
if diff < 0 {
diff = -diff
}
if diff < bestDiff {
bestDiff = diff
targetIdx = i
}
}
}
}
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
}
// 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() {
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 {
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
}
base := glyphBase()
lineHeight := float64(EditorLineHeight())
// 1. Identify the intended line index based on y
// layout.Y values are relative to the text region origin.
// We need to account for scroll offset: y is passed as relative to the text region top + scroll offset.
// So y is the position in the *content*.
visualLine := int(y / lineHeight)
// Group glyphs by their Y-baseline
type lineGroup struct {
y float64
indices []int
}
groups := []lineGroup{}
// Find all unique baseline Ys
// The tap Y is based on line height (top of line).
// We need to associate Y-baseline with visual line index.
// Create map from visual line (0, 1, 2...) to baseline Y.
// Since line height is fixed:
// Line 0 baseline is at some Y0.
// Line 1 baseline is at Y0 + lineHeight.
// Let's find Y0 first.
minY := 1e9
for _, yVal := range layout.Y {
if float64(yVal) < minY {
minY = float64(yVal)
}
}
// Now group by baseline
groups = []lineGroup{} // Reset groups
for i, yVal := range layout.Y {
yFloat := float64(yVal)
lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line
if lineIdx < 0 {
lineIdx = 0
}
// Ensure enough groups
for len(groups) <= lineIdx {
groups = append(groups, lineGroup{
y: minY + float64(len(groups))*lineHeight,
indices: []int{},
})
}
groups[lineIdx].indices = append(groups[lineIdx].indices, i)
}
// If visualLine is out of bounds, clamp
if visualLine < 0 {
visualLine = 0
}
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
// Clamp to last valid group that has indices
for i := len(groups) - 1; i >= 0; i-- {
if len(groups[i].indices) > 0 {
visualLine = i
break
}
}
}
if len(groups[visualLine].indices) == 0 {
return
}
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
}
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 = base + layout.ByteOffsets[bestIdx]
}
}