The IME commit path (swipe-to-type, autocorrect replacement) sends a key.EditEvent with a Range that the editor ignored, causing the replaced region to be duplicated. Add Logic.HandleReplaceRange which deletes the rune range [start,end) and inserts text, converting rune indices to byte offsets (string path: utf8.DecodeRuneInString; chunked path: leading-byte scan). HandleKeyDown now routes key.EditEvent through it. Also fixes two pre-existing ChunkedBuffer correctness bugs the tests exposed: - Delete mis-computed the per-chunk end using a shrinking 'remaining' instead of the absolute end, corrupting multi-chunk deletes. - FullContent derived its chunk bound solely from fileLen, truncating in-memory chunks grown past the old bound by an insert. Adds ime_range_test.go covering insert/replace/delete, unicode, chunked, and swapped bounds. go test -race ./... green.
1012 lines
30 KiB
Go
1012 lines
30 KiB
Go
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
|
|
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
|
|
}
|
|
var newCursor int
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
startByte := buf.RuneIndexToByte(startRune)
|
|
endByte := buf.RuneIndexToByte(endRune)
|
|
if endByte > startByte {
|
|
buf.Delete(startByte, endByte-startByte)
|
|
}
|
|
buf.Insert(startByte, text)
|
|
buf.UpdateLineIndexAfterEdit(startByte, len(text)-(endByte-startByte))
|
|
newCursor = startByte + len(text)
|
|
} else {
|
|
s := TheState.Editor.Buffer
|
|
startByte := runeIndexToByteStr(s, startRune)
|
|
endByte := runeIndexToByteStr(s, endRune)
|
|
if endByte > startByte {
|
|
s = s[:startByte] + s[endByte:]
|
|
}
|
|
TheState.Editor.Buffer = s[:startByte] + text + s[startByte:]
|
|
newCursor = startByte + 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)
|
|
// 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)
|
|
}
|
|
}
|