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 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() } } // 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 } // 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 //