Two feature bodies accumulated in the working tree:
1. Pinch to change the app font size, continuously (no snapping):
- internal/ui/pinch_tracker.go: logic-free touch state machine.
Two-mover formation (the resting palm can land first or last;
movement is the only signal valid for both), pair = the mover
pair whose distance changed most, baseline = press distance
(formDist), lazy pending releases, survivor-scroll forwarding
after a pair break. Robust to ~1 fps frames: a whole pinch can
land in one drain (formDist/brokeFactor/lazy releases).
- render.go: pinch probe (raw pointer events) + grab lifecycle so
the pair is exclusive (scroll sees nothing of the pair) and the
survivor's finger keeps working as a scroll after the pinch.
- state.go/logic.go/session.go/frame.go: app-local float font
scale, content-point pin (buffer byte + offset from baseline,
not a layout point, so rewrap keeps the same character under
the center), restore/font pins, session persistence.
- pinch_test.go, pinch_font_test.go, tag_identity_test.go,
real_draw_probe_test.go: unit + real-Renderer/real-Router tests.
2. Soft keyboard must not shift content:
- Root cause: gioui.org/app calls Router.RevealFocus on any frame
the viewport shrinks (IME open under adjustResize) and
synthesizes a pointer.Scroll nudge aimed at the focused field's
stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
content jump.
- Fix: main.go flags the shrink frame; render.go drains that one
synthetic scroll for the gesture's tag before Update (scroll-
range clamping cannot work: the router UNIONs ranges across
frames). Finger scroll (pointer.Drag) and the flinger are
untouched. reveal_focus_drain_test.go reproduces RevealFocus at
the router level and verifies the drain + zero delta.
Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
983 lines
35 KiB
Go
983 lines
35 KiB
Go
package editor
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"pad/internal/browser"
|
|
"pad/internal/io/pool"
|
|
"pad/internal/io/pool/mock"
|
|
"pad/internal/io/pool/types"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// ConfigEvent represents a window configuration change (resize, orientation).
|
|
// PixelWidth and PixelHeight are the raw pixel dimensions from Gio.
|
|
type ConfigEvent struct {
|
|
PixelWidth int
|
|
PixelHeight int
|
|
}
|
|
|
|
// ScaleEvent represents a metric change (HiDPI scale factor).
|
|
type ScaleEvent struct {
|
|
Scale float32
|
|
// FontScale is the user font-size setting (Metric.PxPerSp / PxPerDp).
|
|
// The shaper draws baselines in sp, so the rendered line pitch in
|
|
// density-dp is EditorLineHeight()*FontScale; all geometry bookkeeping
|
|
// follows it (see EffectiveLineHeight). 0 = unknown, treated as 1.0.
|
|
FontScale float32
|
|
}
|
|
|
|
// ConfigUpdate is a common interface for all configuration updates.
|
|
// Both ConfigEvent and ScaleEvent implement this interface.
|
|
type ConfigUpdate interface {
|
|
apply(*State)
|
|
}
|
|
|
|
func (e ConfigEvent) apply(s *State) {
|
|
s.PixelWidth = e.PixelWidth
|
|
s.PixelHeight = e.PixelHeight
|
|
}
|
|
|
|
func (e ScaleEvent) apply(s *State) {
|
|
s.SetScale(e.Scale)
|
|
s.SetFontScale(e.FontScale)
|
|
}
|
|
|
|
// ResultEvent represents a completed async task result.
|
|
type ResultEvent struct {
|
|
// Future: add result fields here
|
|
}
|
|
|
|
// Logic runs the logic goroutine and provides channels for communication.
|
|
//
|
|
// Single-owner invariant (architecture.md §1): the logic goroutine is the
|
|
// sole reader/writer of l.state. Every other goroutine talks to it through
|
|
// the channels below. The only exception is Inspect, a test-only request
|
|
// channel whose fn still executes on the owner.
|
|
type Logic struct {
|
|
state *State
|
|
browserManager *browser.BrowserManager
|
|
configChan chan ConfigUpdate
|
|
frameChan chan Frame // frames carry the view-state snapshot
|
|
inputChan chan []ui.InputEvent
|
|
layoutChan chan ui.LayoutFeedback
|
|
resultChan chan ResultEvent
|
|
searchQueryChan chan string
|
|
findQueryChan chan string // in-file search text (main-owned "find_bar")
|
|
openFileChan chan string
|
|
retryChan chan string // auto-save retries
|
|
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
|
|
// flushSession: one-shot request to persist the session snapshot now
|
|
// (the OS activity onStop hook, see FlushSession). Buffered 1 so the
|
|
// requester never blocks, even if an earlier flush is still queued.
|
|
flushSession chan struct{}
|
|
inspectChan chan *inspectReq
|
|
|
|
// Per-file write protocol (see requestSave). Workers are a shared pool and
|
|
// the on-disk staging file is per-file, so two concurrent writes for the
|
|
// same file would interleave on the temp file; the protocol keeps at most
|
|
// one write in flight per file and re-issues deferred saves from the write
|
|
// result, so the rename that lands last always carries the newest content.
|
|
// All three maps are touched only on the owner goroutine.
|
|
writeInFlight map[string]int // filename -> fileVersion carried by the in-flight write
|
|
savePending map[string]bool // filename -> save requested while a write was in flight
|
|
retryScheduled map[string]time.Time // filename -> armed-but-unfired backoff retry
|
|
workerPool *pool.WorkerPool
|
|
mockFS pool.FileSystem
|
|
done chan struct{}
|
|
exitWg sync.WaitGroup
|
|
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
|
|
lastEmit time.Time // time of the last frame emission (profiler cadence)
|
|
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
|
|
|
|
// Relaunch state restoration (spec §7, see session.go): session is the
|
|
// snapshot handed in by the cmd layer via BeginRestore (zero = none),
|
|
// restoreFile names the open whose stat/read results are the restore's
|
|
// ("" = none; any other open cancels it). sessionSaver is the cmd layer's
|
|
// file writer; lastSession/lastSessionSave drive the rate-limited
|
|
// change-detected save from emitFrame. All touched only on the owner.
|
|
session SessionState
|
|
restoreFile string
|
|
// restoreScroll/restoreScrollArmed hold the snapshot's scroll offset
|
|
// until it is safe to apply it: only on an emitFrame whose layout pass
|
|
// saw a trustworthy viewport (scale known, size known, restored content
|
|
// present — the layout computes MaxScroll and one-way-clamps the offset,
|
|
// and on device the first ScaleEvent can precede the size ConfigEvent).
|
|
// scaleSeen tracks the first ScaleEvent; restoreContentLanded marks the
|
|
// read result that filled the buffer (FileLen alone is set earlier, by
|
|
// the stat result, and is not a content-arrival signal).
|
|
restoreScroll ui.Dp
|
|
restoreScrollArmed bool
|
|
// restoreScrollLine/restoreScrollSub/restorePinDeadline implement the
|
|
// restore line-pin (see refreshRestorePin in session.go): the
|
|
// line-derived scroll offset is only consistent with the all-estimate
|
|
// WrapIndex, so while relaunch restore is settling, every wrap-count
|
|
// correction landing below the pinned line shifts the offset-to-line
|
|
// mapping and would drag the viewport off the restored line. Until the
|
|
// restored window itself has shaped (or a timeout, or the user or a
|
|
// search takes over), the offset is re-derived from the pinned line
|
|
// after each correction.
|
|
restoreScrollLine int
|
|
restoreScrollSub float64
|
|
restorePinDeadline time.Time
|
|
// fontPin/fontPinM implement the pinch font-pin (see
|
|
// setFontPin/refreshFontPin in session.go): the CONTENT point under the
|
|
// pinch center (glyph byte + offset from its baseline, with a
|
|
// line/fragment/sub-line fallback) and the center's region-relative Y
|
|
// (dp). While armed, every shaped layout re-derives the scroll offset to
|
|
// keep that content point under the center as the font scale — and, a
|
|
// few frames later, the rewrap — changes.
|
|
fontPin contentPin
|
|
fontPinM float64
|
|
fontPinArmed bool
|
|
fontPinDeadline time.Time
|
|
scaleSeen bool
|
|
restoreContentLanded bool
|
|
sessionSaver func(SessionState)
|
|
lastSession SessionState
|
|
lastSessionSave time.Time
|
|
}
|
|
|
|
// NewLogic creates a new Logic instance, accepting an optional mockFS.
|
|
func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
|
|
state := NewState()
|
|
TheState = state
|
|
// The openfunc (e.g. the Android Termux bridge) is kept on TheState.open
|
|
// as an optional external hook, but it is NOT the tap path: tapping a file
|
|
// must open it in the in-app editor (doc/spec.md). The previous wiring set
|
|
// ui.OpenFile = openfunc, which routed every tap through the external
|
|
// bridge and bypassed the editor entirely.
|
|
TheState.open = openfunc
|
|
ui.OpenFile = func(path string) { OpenFile(path) }
|
|
|
|
// Initialize mock filesystem if nil
|
|
mockFS := mfs
|
|
if mockFS == nil {
|
|
mockFS = mock.NewFileSystem()
|
|
populateMockFileSystem(mockFS)
|
|
}
|
|
|
|
// Initialize worker pool
|
|
wp := pool.NewWorkerPool(8) // Increased worker pool size to 8
|
|
wp.Start()
|
|
|
|
state.Browser.CurrentPath = path
|
|
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
|
|
|
|
TheLogic = &Logic{
|
|
state: state,
|
|
browserManager: bm,
|
|
configChan: make(chan ConfigUpdate),
|
|
frameChan: make(chan Frame, 1),
|
|
inputChan: make(chan []ui.InputEvent),
|
|
layoutChan: make(chan ui.LayoutFeedback),
|
|
resultChan: make(chan ResultEvent),
|
|
searchQueryChan: make(chan string),
|
|
findQueryChan: make(chan string),
|
|
openFileChan: make(chan string),
|
|
retryChan: make(chan string, 1), // Buffered channel
|
|
autosaveChan: make(chan struct{}),
|
|
flushSession: make(chan struct{}, 1),
|
|
inspectChan: make(chan *inspectReq),
|
|
writeInFlight: make(map[string]int),
|
|
savePending: make(map[string]bool),
|
|
retryScheduled: make(map[string]time.Time),
|
|
workerPool: wp,
|
|
mockFS: mockFS,
|
|
done: make(chan struct{}),
|
|
}
|
|
return TheLogic
|
|
}
|
|
|
|
// ConfigChan returns the unified config channel for the logic goroutine.
|
|
// Accepts ConfigEvent (size) and ScaleEvent (scale factor).
|
|
func (l *Logic) ConfigChan() chan<- ConfigUpdate {
|
|
return l.configChan
|
|
}
|
|
|
|
// FrameChan returns the frame channel for the logic goroutine.
|
|
func (l *Logic) FrameChan() <-chan Frame {
|
|
return l.frameChan
|
|
}
|
|
|
|
// InputChan returns the input channel for the logic goroutine.
|
|
func (l *Logic) InputChan() chan<- []ui.InputEvent {
|
|
return l.inputChan
|
|
}
|
|
|
|
// LayoutChan returns the glyph layout feedback channel.
|
|
func (l *Logic) LayoutChan() chan<- ui.LayoutFeedback {
|
|
return l.layoutChan
|
|
}
|
|
|
|
// ResultChan returns the result channel for the logic goroutine.
|
|
func (l *Logic) ResultChan() chan<- ResultEvent {
|
|
return l.resultChan
|
|
}
|
|
|
|
// SearchQueryChan returns the search query channel for the logic goroutine.
|
|
// The main goroutine sends updated search text here when it detects a change.
|
|
func (l *Logic) SearchQueryChan() chan<- string {
|
|
return l.searchQueryChan
|
|
}
|
|
|
|
// FindQueryChan returns the in-file search query channel. The main goroutine
|
|
// forwards the "find_bar" widget's text here when it changes (see
|
|
// search.go).
|
|
func (l *Logic) FindQueryChan() chan<- string {
|
|
return l.findQueryChan
|
|
}
|
|
|
|
// ClipboardSetChan returns the channel the logic goroutine uses to request a
|
|
// system clipboard write; the main goroutine executes the Gio clipboard op.
|
|
func (l *Logic) ClipboardSetChan() <-chan string {
|
|
return l.state.clipboardSetChan
|
|
}
|
|
|
|
// PasteReqChan returns the channel the logic goroutine uses to request the
|
|
// system clipboard content for paste.
|
|
func (l *Logic) PasteReqChan() <-chan struct{} {
|
|
return l.state.pasteReqChan
|
|
}
|
|
|
|
// PasteChan delivers system clipboard content to the logic goroutine for
|
|
// paste (tests use it to inject clipboard text without a main loop).
|
|
func (l *Logic) PasteChan() chan<- string {
|
|
return l.state.pasteChan
|
|
}
|
|
|
|
var TheState *State
|
|
var TheLogic *Logic
|
|
|
|
// Run runs the logic goroutine loop.
|
|
func (l *Logic) Run() {
|
|
l.exitWg.Add(1)
|
|
defer l.exitWg.Done()
|
|
|
|
// Dispatch initial directory index build on startup
|
|
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
|
|
|
|
// Relaunch restoration (spec §7): re-open the last file if a session
|
|
// was handed in before Run (BeginRestore). The browser index is still
|
|
// built: the back button must land on a populated browser.
|
|
if l.session.File != "" {
|
|
l.restoreFile = l.session.File
|
|
l.openFile(l.session.File)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-l.done:
|
|
// Settle in-flight file writes before exiting: the post-exit
|
|
// synchronous FlushAll and workerPool.Stop must not race a
|
|
// straggling worker write, whose rename could otherwise land after
|
|
// the final flush and promote a stale snapshot.
|
|
l.drainWrites()
|
|
return
|
|
case <-l.flushSession:
|
|
// Persist now, bypassing the rate limit: the activity is going
|
|
// away (recents-wipe or app switch) and the process may die
|
|
// shortly after this returns.
|
|
l.flushSessionSave()
|
|
case update := <-l.configChan:
|
|
update.apply(l.state)
|
|
if _, ok := update.(ScaleEvent); ok {
|
|
l.scaleSeen = true
|
|
}
|
|
l.emitFrame()
|
|
case fb := <-l.layoutChan:
|
|
// Store the full GlyphLayout on editor state.
|
|
// Derive LastLineY from it for scroll clamping.
|
|
layout := fb.GlyphLayout
|
|
l.state.Editor.GlyphLayout = layout
|
|
var derivedLastLineY ui.Dp
|
|
if len(layout.Y) > 0 {
|
|
derivedLastLineY = layout.Y[len(layout.Y)-1]
|
|
}
|
|
// Wrap-count correction (see WrapIndex): the layout describes the
|
|
// window it was shaped for (fb.WindowStartLine); apply its visual
|
|
// line counts to those lines, but only if no edit has shifted the
|
|
// lines since shaping (fb.EditSeq correlates with the content).
|
|
if fb.EditSeq == l.state.Editor.EditSeq {
|
|
l.state.applyWrapCounts(fb)
|
|
// Restore line-pin (see refreshRestorePin): a correction
|
|
// landing below the pinned line shifted the offset-to-line
|
|
// mapping, so re-derive the offset from the pinned line under
|
|
// the corrected index. The restored window's own shaping means
|
|
// its neighborhood is real and the pin can stand down.
|
|
if l.restoreScrollLine >= 0 {
|
|
if fb.WindowStartLine == l.restoreScrollLine {
|
|
l.restoreScrollLine = -1
|
|
} else {
|
|
l.refreshRestorePin()
|
|
}
|
|
}
|
|
// Pinch font-pin (see refreshFontPin): the fresh layout may
|
|
// have rewrapped the pinned line; re-derive so the pinned
|
|
// content point stays under the pinch center.
|
|
if l.fontPinArmed {
|
|
l.refreshFontPin(fb)
|
|
}
|
|
// Search settle (see EditorState.findSettle): the shaping above
|
|
// may have corrected the wrap counts around a find-jumped
|
|
// viewport; re-scroll while the correction still matters.
|
|
if l.state.Editor.findSettle() {
|
|
l.emitFrame()
|
|
}
|
|
}
|
|
if derivedLastLineY != l.state.LastLineY {
|
|
l.state.LastLineY = derivedLastLineY
|
|
l.emitFrame()
|
|
}
|
|
case events := <-l.inputChan:
|
|
for _, evt := range events {
|
|
evt.Handler(evt.Data)
|
|
}
|
|
l.emitFrame()
|
|
case query := <-l.searchQueryChan:
|
|
if query != l.state.Browser.Query {
|
|
l.state.Browser.Query = query
|
|
if l.state.page == BrowserPage {
|
|
browser.HandleSearch(&l.state.Browser, query)
|
|
}
|
|
}
|
|
l.emitFrame()
|
|
case q := <-l.findQueryChan:
|
|
l.state.Editor.findSetQuery(q)
|
|
l.emitFrame()
|
|
case path := <-l.openFileChan:
|
|
l.openFile(path)
|
|
case filename := <-l.retryChan:
|
|
log.Printf("Logic: Retrying save for %s", filename)
|
|
delete(l.retryScheduled, filename)
|
|
l.requestSave(filename)
|
|
case <-l.autosaveChan:
|
|
// Auto-save debounce tick. The timer goroutine only sent a token;
|
|
// the owner snapshots content and dispatches the write.
|
|
l.saveTimer = nil
|
|
l.requestSave(l.state.Editor.Filename)
|
|
case p := <-l.state.pasteChan:
|
|
// Clipboard content arrived from the main goroutine: insert it
|
|
// (replacing any live selection, per the selection-aware edit rule).
|
|
HandlePaste(p)
|
|
l.emitFrame()
|
|
case req := <-l.inspectChan:
|
|
// Test-only: fn runs on the owner, preserving single ownership.
|
|
req.resp <- req.fn(l.state)
|
|
case res := <-l.workerPool.ResultChan():
|
|
l.handleWorkerResult(res)
|
|
case <-l.resultChan:
|
|
l.emitFrame()
|
|
case cmd := <-l.debugCmdC:
|
|
l.applyDebugCmd(cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// openFile starts loading path in the editor (a browser row tap or the
|
|
// relaunch restore). Any open of a file that is not the in-flight restore
|
|
// cancels the restore: its late stat/read results must not re-apply the
|
|
// snapshot's positions to a different file. Must be called on the logic
|
|
// goroutine.
|
|
func (l *Logic) openFile(path string) {
|
|
if l.restoreFile != "" && l.restoreFile != path {
|
|
l.abortRestore() // a different open cancels the in-flight one
|
|
}
|
|
l.releaseFontPin() // the pin names lines of the previous file
|
|
// Discard find results for the previous file; the query is kept
|
|
// (see EditorState.findReset) and re-scanned against the new file.
|
|
TheState.Editor.findReset()
|
|
// Create chunked buffer for virtual scrolling
|
|
chunkSize := DefaultChunkSize
|
|
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
|
|
cb.SetWorkerPool(l.workerPool)
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
|
|
// Dispatch stat task to get file size
|
|
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
|
|
}
|
|
|
|
// emitFrame computes the current frame, records a profiler probe (if enabled),
|
|
// and hands it to the main goroutine. Centralizing emission here ensures the
|
|
// in-app profiler (PerfRecord) sees every frame exactly once, on the owner
|
|
// goroutine. Must be called on the logic goroutine.
|
|
func (l *Logic) emitFrame() {
|
|
// Relaunch snapshot (spec §7): persist when the state has changed and
|
|
// the rate limit elapsed (tiny JSON file, see session.go).
|
|
l.saveSessionIfChanged()
|
|
elems := l.state.layout(l.browserManager)
|
|
// Relaunch restore (spec §7): land the armed restore scroll AFTER the
|
|
// layout pass above (it refreshed MaxScroll for the current viewport and
|
|
// one-way-clamps any offset set against an earlier, smaller one). The
|
|
// guard defers the application until the viewport is trustworthy — on
|
|
// device the first ScaleEvent can precede the size ConfigEvent. The
|
|
// re-layout makes this frame carry the restored viewport. The restore is
|
|
// complete once the scroll lands; drop restoreFile so a later open of
|
|
// this same file is treated as a fresh one.
|
|
if l.restoreScrollArmed && l.maybeApplyRestoreScroll() {
|
|
l.restoreFile = ""
|
|
elems = l.state.layout(l.browserManager)
|
|
}
|
|
now := time.Now()
|
|
if PerfRecord != nil {
|
|
var delta time.Duration
|
|
if !l.lastEmit.IsZero() {
|
|
delta = now.Sub(l.lastEmit)
|
|
}
|
|
l.lastEmit = now
|
|
s := l.state
|
|
rec := ProbeRecord{T: now, DeltaMs: float64(delta.Nanoseconds()) / 1e6, Page: pageName(s.page)}
|
|
if s.page == EditorPage {
|
|
rec.ScrollDP = float32(s.ScrollOffset)
|
|
rec.MaxScrollDP = float32(s.MaxScroll)
|
|
rec.VisStart = s.VisibleStart
|
|
rec.VisEnd = s.VisibleEnd
|
|
if cb := s.Editor.ChunkedBuffer; cb != nil {
|
|
if li := cb.LineIndex; li != nil {
|
|
rec.TotalLines = li.LineCount()
|
|
}
|
|
}
|
|
} else {
|
|
rec.ScrollDP = float32(s.Browser.ScrollOffset)
|
|
}
|
|
PerfRecord(rec)
|
|
}
|
|
f := l.frameOf(elems)
|
|
select {
|
|
case l.frameChan <- f:
|
|
default:
|
|
// Main has not consumed the previous frame yet. Frames are snapshots
|
|
// of the latest state, so the newest is always the most valuable:
|
|
// replace the unread one instead of dropping this one. A dropped
|
|
// final frame would never be re-emitted (emission is event-driven),
|
|
// leaving the consumer one state behind until the next event. Still
|
|
// non-blocking: a drain from a buffer whose consumer is gone (e.g.
|
|
// the post-done write drain) still just removes the unread frame.
|
|
select {
|
|
case <-l.frameChan:
|
|
default:
|
|
return // consumer gone; nothing to replace into
|
|
}
|
|
select {
|
|
case l.frameChan <- f:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// EnableDebugCmdPoll starts a background poller (debug-only) that watches
|
|
// <dir>/cmd for a one-shot debug command (see applyDebugCmd) and forwards
|
|
// it to the owner for
|
|
// application. Used to jump the editor to specific scroll offsets for
|
|
// performance/clamping validation. The poller does the (blocking) file read
|
|
// off the owner and sends the command via debugCmdC; the owner applies it.
|
|
func (l *Logic) EnableDebugCmdPoll(dir string) {
|
|
l.debugCmdC = make(chan string, 1)
|
|
l.exitWg.Add(1)
|
|
go func() {
|
|
defer l.exitWg.Done()
|
|
t := time.NewTicker(120 * time.Millisecond)
|
|
defer t.Stop()
|
|
cmdPath := filepath.Join(dir, "cmd")
|
|
for {
|
|
select {
|
|
case <-l.done:
|
|
return
|
|
case <-t.C:
|
|
b, err := os.ReadFile(cmdPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
cmd := strings.TrimSpace(string(b))
|
|
if cmd == "" {
|
|
continue
|
|
}
|
|
// Consume the command so it is applied exactly once.
|
|
_ = os.WriteFile(cmdPath, nil, 0o644)
|
|
select {
|
|
case l.debugCmdC <- cmd:
|
|
case <-l.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// applyDebugCmd applies a one-shot debug command from the cmd-file poller.
|
|
// Commands: "open <path>" (any page), and, on the editor page, "top",
|
|
// "bottom", "frac <0..1>", "dp <int>", "pinch <factor>" (relative app font
|
|
// scale, as the renderer's pinch probe would deliver) and "fontsize <v>"
|
|
// (absolute app font scale). Must be called on the logic goroutine.
|
|
func (l *Logic) applyDebugCmd(cmd string) {
|
|
s := l.state
|
|
fields := strings.Fields(cmd)
|
|
if len(fields) == 0 {
|
|
return
|
|
}
|
|
// "open <path>" works from any page: it opens the file and switches to
|
|
// the editor. The pre-release frame profile (scripts/profile_emulator.sh)
|
|
// uses it to open a known scrollable file deterministically instead of
|
|
// pixel-tapping the browser list.
|
|
if fields[0] == "open" {
|
|
if len(fields) < 2 {
|
|
log.Printf("DebugCmd: open needs a path")
|
|
return
|
|
}
|
|
OpenFile(fields[1])
|
|
l.emitFrame() // OpenFile only mutates state (the tap path emits via its handler)
|
|
return
|
|
}
|
|
// App-local font scale (pinch zoom). Drives the full logic->frame->render
|
|
// path the same way a real pinch does (HandleFontPinch is the handler a
|
|
// FontPinchEvent carries); adb has no two-finger input, so these
|
|
// commands are the on-emulator test hook. `pinch` anchors at the CENTER
|
|
// of the editor region (where a real pinch usually starts); `fontsize`
|
|
// is an absolute top-anchored set.
|
|
if fields[0] == "pinch" || fields[0] == "fontsize" {
|
|
if s.page != EditorPage {
|
|
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
|
return
|
|
}
|
|
if len(fields) < 2 {
|
|
log.Printf("DebugCmd: %s needs a value", fields[0])
|
|
return
|
|
}
|
|
f, err := strconv.ParseFloat(fields[1], 32)
|
|
if err != nil || f <= 0 {
|
|
log.Printf("DebugCmd: bad %s value %q", fields[0], fields[1])
|
|
return
|
|
}
|
|
if fields[0] == "pinch" {
|
|
if f > 100 { // a single frame's pinch never spans this much
|
|
log.Printf("DebugCmd: pinch factor out of range: %v", f)
|
|
return
|
|
}
|
|
HandleFontPinch(ui.FontPinchEvent{
|
|
Scale: float32(f),
|
|
Center: ui.Point{
|
|
X: s.EditorRegion.X + s.EditorRegion.W/2,
|
|
Y: s.EditorRegion.Y + s.EditorRegion.H/2,
|
|
},
|
|
})
|
|
} else {
|
|
SetAppFontScale(float32(f))
|
|
}
|
|
log.Printf("DebugCmd: %q -> appFontScale=%.4f scroll=%d", cmd, s.appFontScale, int(s.ScrollOffset))
|
|
l.emitFrame()
|
|
return
|
|
}
|
|
if s.page != EditorPage {
|
|
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
|
return
|
|
}
|
|
var target ui.Dp
|
|
switch fields[0] {
|
|
case "top":
|
|
target = 0
|
|
case "bottom":
|
|
target = s.MaxScroll
|
|
case "frac":
|
|
if len(fields) < 2 {
|
|
return
|
|
}
|
|
f, err := strconv.ParseFloat(fields[1], 64)
|
|
if err != nil || f < 0 || f > 1 {
|
|
log.Printf("DebugCmd: bad frac %q", fields[1])
|
|
return
|
|
}
|
|
target = ui.Dp(float64(f) * float64(s.MaxScroll))
|
|
case "dp":
|
|
if len(fields) < 2 {
|
|
return
|
|
}
|
|
n, err := strconv.Atoi(fields[1])
|
|
if err != nil {
|
|
log.Printf("DebugCmd: bad dp %q", fields[1])
|
|
return
|
|
}
|
|
target = ui.Dp(n)
|
|
default:
|
|
log.Printf("DebugCmd: unknown %q", cmd)
|
|
return
|
|
}
|
|
if target < 0 {
|
|
target = 0
|
|
}
|
|
if target > s.MaxScroll {
|
|
target = s.MaxScroll
|
|
}
|
|
l.releaseFontPin() // a debug scroll takes over the viewport
|
|
s.ScrollOffset = target
|
|
log.Printf("DebugCmd: %q -> scroll=%d maxScroll=%d", cmd, int(s.ScrollOffset), int(s.MaxScroll))
|
|
l.emitFrame()
|
|
}
|
|
|
|
// fullContentBytes reconstructs the full file content from the chunked
|
|
// buffer (or the deprecated full Buffer). Returns ok=false on error.
|
|
// Must be called on the logic goroutine.
|
|
func (l *Logic) fullContentBytes() ([]byte, bool) {
|
|
if l.state.Editor.ChunkedBuffer != nil {
|
|
fullContent, err := l.state.Editor.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error reconstructing full content: %v", err)
|
|
return nil, false
|
|
}
|
|
return []byte(fullContent), true
|
|
}
|
|
return []byte(l.state.Editor.Buffer), true
|
|
}
|
|
|
|
// markDirty triggers the auto-save debounce timer.
|
|
// Must be called on the logic goroutine.
|
|
//
|
|
// The timer callback runs on a timer goroutine and only sends a token on
|
|
// autosaveChan; the owner (Run loop) does all state reads and the worker
|
|
// dispatch. This keeps the timer goroutine out of state (architecture.md §1).
|
|
func (l *Logic) markDirty() {
|
|
if l.state.Editor.Filename == "" {
|
|
return
|
|
}
|
|
l.state.Editor.fileVersion[l.state.Editor.Filename]++
|
|
|
|
// 1s debounce
|
|
if l.saveTimer != nil {
|
|
l.saveTimer.Stop()
|
|
}
|
|
l.saveTimer = time.AfterFunc(1*time.Second, func() {
|
|
l.autosaveChan <- struct{}{}
|
|
})
|
|
}
|
|
|
|
// handleWorkerResult processes results from the worker pool.
|
|
func (l *Logic) handleWorkerResult(res pool.Result) {
|
|
if res.IsBrowserResult() {
|
|
l.browserManager.HandleResult(res)
|
|
} else if res.TaskType == pool.TypeReadFile {
|
|
if res.Success {
|
|
if content, ok := res.Data.([]byte); ok {
|
|
if l.state.Editor.ChunkedBuffer != nil {
|
|
// Full-load the in-range file: set the whole content (split into
|
|
// resident chunks). No lazy load, so no stale-disk re-read.
|
|
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
|
|
l.state.Editor.ChunkedBuffer.SetContent(content)
|
|
// Also update the Buffer field for backward compatibility and for tests checking it.
|
|
l.state.Editor.Buffer = string(content)
|
|
} else {
|
|
// Fallback: populate the deprecated Buffer field
|
|
l.state.Editor.Buffer = string(content)
|
|
}
|
|
// Relaunch restoration (spec §7): the content is in memory, so
|
|
// land the snapshot's cursor/selection (clamped to the file)
|
|
// and re-scan the restored find query. Path-guarded: a read
|
|
// result for a file the user has since replaced must not apply
|
|
// the snapshot to the replacement's buffer.
|
|
if res.FilePath == l.state.Editor.Filename && l.restoreFile == res.FilePath {
|
|
l.applyRestorePositions(len(content))
|
|
l.restoreContentLanded = true
|
|
// The snapshot has landed: resume session saving. restoreFile
|
|
// and the armed scroll stay until the scroll itself lands in
|
|
// the emitFrame hook (which needs this content for a real
|
|
// MaxScroll), so a different open in between still aborts via
|
|
// openFile's restoreFile guard.
|
|
// Re-scan the restored query only when the bar was open:
|
|
// with the bar closed, the result would be dropped (the apply
|
|
// gate requires Visible) and Scanning would stay stuck true;
|
|
// findShow re-scans on the next open instead.
|
|
if f := &l.state.Editor.Find; f.Visible && f.Query != "" && !f.Scanning {
|
|
l.state.Editor.findDispatchScan()
|
|
}
|
|
// No armed scroll: the snapshot has fully landed with the
|
|
// content; lift the save suppression now (the emitFrame
|
|
// hook does this when a scroll does land).
|
|
if !l.restoreScrollArmed {
|
|
l.session = SessionState{}
|
|
}
|
|
}
|
|
}
|
|
} else if res.FilePath == l.restoreFile {
|
|
// The restored file's content could not be read: drop the
|
|
// restore (the editor keeps the empty file view it has).
|
|
l.abortRestore()
|
|
}
|
|
} else if res.TaskType == pool.TypeReadChunk {
|
|
// In-range files load fully via SetContent, so this is only a fallback.
|
|
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
|
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
|
|
|
|
if res.Success {
|
|
if chunk, ok := res.Data.([]byte); ok {
|
|
for len(cb.chunks) <= res.ChunkIdx {
|
|
cb.chunks = append(cb.chunks, nil)
|
|
}
|
|
cb.chunks[res.ChunkIdx] = chunk
|
|
}
|
|
} else {
|
|
log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, res.Error)
|
|
}
|
|
}
|
|
} else if res.TaskType == pool.TypeStatFile {
|
|
if res.Success {
|
|
if stat, ok := res.Data.(*pool.FileStat); ok {
|
|
// Relaunch restoration (spec §7): the file exists, so land
|
|
// the snapshot's find state now (the cursor/selection land
|
|
// with the content, the query re-scans against the new file).
|
|
if res.FilePath == l.restoreFile && l.restoreFile != "" {
|
|
f := &l.state.Editor.Find
|
|
f.Query = l.session.FindQuery
|
|
f.Visible = l.session.FindVisible
|
|
f.RestoreMatch = l.session.FindCurByte
|
|
f.Restoring = l.session.FindVisible
|
|
}
|
|
// Size guard: refuse to edit files above the limit. The browser can
|
|
// still list them; the editor shows a "too large to edit" notice.
|
|
if stat.Size > MaxEditableFileSize {
|
|
if l.restoreFile != "" {
|
|
l.abortRestore() // no content load follows; nothing lands
|
|
}
|
|
l.state.Editor.TooLarge = true
|
|
l.state.Editor.TooLargeSize = stat.Size
|
|
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
|
|
l.emitFrame()
|
|
return
|
|
}
|
|
if l.state.Editor.ChunkedBuffer != nil {
|
|
l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size)
|
|
// Full-load: read the whole file and dispatch a line index build.
|
|
l.workerPool.Dispatch(pool.NewReadFileTask(stat.Path, l.mockFS))
|
|
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, l.mockFS))
|
|
}
|
|
}
|
|
} else if res.FilePath == l.restoreFile && l.restoreFile != "" {
|
|
// The restored file is gone (deleted/moved since the last
|
|
// session): fall back to the browser instead of showing an
|
|
// empty editor.
|
|
l.abandonRestore()
|
|
}
|
|
} else if res.TaskType == pool.TypeBuildLineIndex {
|
|
if res.Success {
|
|
if idx, ok := res.Data.(*types.LineIndex); ok {
|
|
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
|
cb.LineIndex = idx
|
|
// The wrap index is created with the line index: same line
|
|
// set, same lifecycle. Counts start at the all-ones
|
|
// estimate and are corrected by shaped frames.
|
|
cb.WrapIndex = NewWrapIndex(idx.LineCount())
|
|
}
|
|
}
|
|
}
|
|
} else if res.TaskType == pool.TypeWriteFile {
|
|
f := res.FilePath
|
|
wrote, tracked := l.writeInFlight[f]
|
|
delete(l.writeInFlight, f)
|
|
pending := l.savePending[f]
|
|
delete(l.savePending, f)
|
|
if res.Success {
|
|
if tracked {
|
|
// Record the version whose content was actually written (the
|
|
// snapshot version), not the version now: edits made while the
|
|
// write was in flight sit ahead of it and trigger the re-issue
|
|
// below.
|
|
l.state.Editor.lastWriteVersion[f] = wrote
|
|
}
|
|
l.state.Editor.SetWriteFailed(f, false)
|
|
// Latest-state-wins re-issue: a save was deferred while this write
|
|
// was in flight, or an edit arrived during it — write the newer
|
|
// content now (only the active file is reconstructable).
|
|
if f == l.state.Editor.Filename && (pending || l.state.Editor.fileVersion[f] > l.state.Editor.lastWriteVersion[f]) {
|
|
l.requestSave(f)
|
|
}
|
|
} else {
|
|
log.Printf("Auto-save failed for %s: %v", f, res.Error)
|
|
l.state.Editor.SetWriteFailed(f, true)
|
|
|
|
// Trigger retry logic: exponential backoff
|
|
attempts := l.state.Editor.IncrementRetryAttempts(f)
|
|
// Simple backoff: 1s, 2s, 4s, 8s... max 30s
|
|
delay := time.Duration(1<<(attempts-1)) * time.Second
|
|
if delay > 30*time.Second {
|
|
delay = 30 * time.Second
|
|
}
|
|
l.retryScheduled[f] = time.Now().Add(delay)
|
|
filename := f
|
|
log.Printf("Scheduling retry for %s in %v (attempt %d)", filename, delay, attempts)
|
|
time.AfterFunc(delay, func() {
|
|
log.Printf("Firing retry for %s", filename)
|
|
select {
|
|
case l.retryChan <- filename:
|
|
default:
|
|
// A token for the same file is already queued; it will
|
|
// re-snapshot the latest content when it fires. Dropping
|
|
// keeps the timer goroutine from ever blocking.
|
|
log.Printf("Retry token for %s dropped: one already queued", filename)
|
|
}
|
|
})
|
|
}
|
|
} else if res.TaskType == pool.TypeSearch {
|
|
if res.Success {
|
|
l.state.Editor.applySearchResult(res)
|
|
}
|
|
}
|
|
l.emitFrame()
|
|
}
|
|
|
|
// State returns the current state.
|
|
func (l *Logic) State() *State {
|
|
return l.state
|
|
}
|
|
|
|
// PruneMaps removes old entries to keep memory usage bounded.
|
|
// Keeps entries for the last 1000 files.
|
|
func (l *Logic) PruneMaps() {
|
|
if len(l.state.Editor.fileVersion) <= 1000 {
|
|
return
|
|
}
|
|
|
|
// Simply clear the maps for now. A true LRU would require
|
|
// tracking access times.
|
|
l.state.Editor.fileVersion = make(map[string]int)
|
|
l.state.Editor.lastWriteVersion = make(map[string]int)
|
|
l.state.Editor.writeFailed = make(map[string]bool)
|
|
l.state.Editor.retryAttempts = make(map[string]int)
|
|
}
|
|
|
|
// Done signals the logic goroutine to stop.
|
|
func (l *Logic) Done() {
|
|
close(l.done)
|
|
}
|
|
|
|
// WaitForExit blocks until the logic goroutine has fully stopped. After
|
|
// this returns, the caller (e.g. Shutdown) may touch state single-threaded.
|
|
func (l *Logic) WaitForExit() {
|
|
l.exitWg.Wait()
|
|
}
|
|
|
|
// FlushAll triggers synchronous writes for all dirty files.
|
|
//
|
|
// Must be called either from the logic goroutine (e.g. via GoToBrowser) or
|
|
// after the logic goroutine has fully stopped (Shutdown). It touches state
|
|
// directly, so it must never run concurrently with Run (single-owner
|
|
// invariant, architecture.md §1).
|
|
func (l *Logic) FlushAll() {
|
|
for filename := range l.state.Editor.fileVersion {
|
|
if l.state.Editor.IsDirty() && l.state.Editor.Filename == filename {
|
|
if _, inflight := l.writeInFlight[filename]; inflight {
|
|
// A worker is already writing this file and we cannot block the
|
|
// owner (we are it): defer. The write's result handler re-issues
|
|
// a fresh write while the file is still dirty, so the disk ends
|
|
// up with the latest content.
|
|
l.savePending[filename] = true
|
|
continue
|
|
}
|
|
content, ok := l.fullContentBytes()
|
|
if !ok {
|
|
continue
|
|
}
|
|
// In a real app, this would be a blocking call to the FS
|
|
l.mockFS.WriteFileAtomic(filename, content)
|
|
l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename]
|
|
}
|
|
}
|
|
}
|
|
|
|
// requestSave is the single entry point for "persist the active file now"
|
|
// (autosave tick, retry token, deferred re-issue). It implements the
|
|
// per-file write protocol:
|
|
//
|
|
// - at most one write per file is in flight at any time. The worker pool is
|
|
// shared and the on-disk staging file is per-file, so two concurrent
|
|
// writes for the same file would interleave on the staging file and could
|
|
// rename a byte-mixture into place;
|
|
// - each write carries the file version at the moment its content was
|
|
// snapshotted (writeInFlight[f]); on success exactly that version is
|
|
// recorded as written, so any edit that arrived while the write was in
|
|
// flight leaves the file dirty and the result handler re-issues a fresh
|
|
// write with the newer content;
|
|
// - a save requested while a write is in flight is deferred (savePending)
|
|
// and re-issued by the result handler, so "last rename wins" always
|
|
// coincides with "newest snapshot wins".
|
|
//
|
|
// Must be called on the owner goroutine.
|
|
func (l *Logic) requestSave(filename string) {
|
|
if filename == "" || filename != l.state.Editor.Filename {
|
|
// Only the active file's content lives in memory (its ChunkedBuffer);
|
|
// a save for any previously opened file cannot be reconstructed.
|
|
return
|
|
}
|
|
if _, inflight := l.writeInFlight[filename]; inflight {
|
|
l.savePending[filename] = true
|
|
return
|
|
}
|
|
content, ok := l.fullContentBytes()
|
|
if !ok {
|
|
return
|
|
}
|
|
l.writeInFlight[filename] = l.state.Editor.fileVersion[filename]
|
|
l.workerPool.DispatchNonBlocking(pool.NewWriteFileTask(filename, content, l.mockFS))
|
|
}
|
|
|
|
const (
|
|
// writeDrainTimeout bounds the post-done wait for in-flight writes so a
|
|
// pathologically failing write can never hang shutdown.
|
|
writeDrainTimeout = 5 * time.Second
|
|
// writeDrainTick is the idle poll interval of the drain loop.
|
|
writeDrainTick = 20 * time.Millisecond
|
|
)
|
|
|
|
// drainWrites waits for in-flight file writes (and armed retries) to settle
|
|
// after the done signal, so that Shutdown's post-exit synchronous FlushAll
|
|
// and workerPool.Stop cannot race a straggling worker write. The write
|
|
// protocol may re-issue follow-up writes while draining; the deadline bounds
|
|
// the wait. Must be called on the owner goroutine.
|
|
func (l *Logic) drainWrites() {
|
|
if len(l.writeInFlight) == 0 && len(l.retryScheduled) == 0 {
|
|
return
|
|
}
|
|
log.Printf("Logic: draining %d in-flight write(s) before exit", len(l.writeInFlight))
|
|
deadline := time.Now().Add(writeDrainTimeout)
|
|
for {
|
|
if len(l.writeInFlight) == 0 && len(l.retryScheduled) == 0 {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
log.Printf("Logic: write drain timed out with %d in flight; exiting (per-write unique temp files keep every rename a complete snapshot)", len(l.writeInFlight))
|
|
return
|
|
}
|
|
select {
|
|
case res := <-l.workerPool.ResultChan():
|
|
l.handleWorkerResult(res)
|
|
case filename := <-l.retryChan:
|
|
delete(l.retryScheduled, filename)
|
|
l.requestSave(filename)
|
|
case <-l.autosaveChan:
|
|
l.saveTimer = nil
|
|
l.requestSave(l.state.Editor.Filename)
|
|
case <-time.After(writeDrainTick):
|
|
// No events: re-check settle and deadline.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the logic goroutine and worker pool.
|
|
// The logic goroutine is stopped FIRST so that FlushAll can touch state
|
|
// single-threaded; the worker pool is stopped last so in-flight results
|
|
// still have a reader until then.
|
|
func (l *Logic) Shutdown() {
|
|
l.Done()
|
|
l.WaitForExit()
|
|
l.FlushAll()
|
|
// Final relaunch snapshot (spec §7): after exit the caller owns the
|
|
// state single-threaded, like FlushAll above, so one unconditional
|
|
// save makes the file exact for the next launch.
|
|
if l.sessionSaver != nil {
|
|
l.sessionSaver(l.SnapshotSession())
|
|
}
|
|
l.workerPool.Stop()
|
|
}
|