package main import ( "encoding/json" "flag" "io" "log" "os" "path/filepath" "strings" "sync" "time" "gioui.org/app" "gioui.org/font/gofont" "gioui.org/io/clipboard" "gioui.org/io/key" "gioui.org/io/transfer" "gioui.org/op" "gioui.org/text" "gioui.org/unit" "gioui.org/widget" "pad/internal/editor" "pad/internal/io/pool/real" "pad/internal/perf" "pad/internal/ui" ) func main() { go func() { w := new(app.Window) w.Option(app.Title("Pad")) w.Option(app.Size(unit.Dp(390), unit.Dp(844))) if err := run(w); err != nil { log.Fatal(err) } os.Exit(0) }() app.Main() } func run(w *app.Window) error { var ops op.Ops shaper := text.NewShaper(text.WithCollection(gofont.Collection())) // Determine root directory for real filesystem rootDir := flag.String("root", startpath, "root directory for the filesystem") flag.Parse() // Initialize the real filesystem rooted at the system root "/" // so that the browser can navigate the entire system. fs := real.NewRealFileSystem("/") // Get the absolute path of the startup directory startAbs, err := filepath.Abs(*rootDir) if err != nil { startAbs = *rootDir } log.Printf("using filesystem at / (startup directory: %s)", startAbs) logic := editor.NewLogic(fs, startAbs, OpenFile) activeLogic = logic renderer := ui.New(ui.Theme{FontSize: 14}, shaper) // In-app frame profiler (default off). Enabled by the presence of a marker // file so the shipping APK needs no rebuild to toggle profiling. When on, // it records logic-frame cadence plus scroll/visible-range context to a CSV // and logs rolling percentiles; it also enables the scroll-jump debug poller. const perfDir = "/storage/emulated/0/PadPerf" var prof *perf.Profiler var perfOn bool var presentN int var presentStart time.Time // shiftDown tracks the hardware shift key on the MAIN goroutine. // On Android Gio's JNI bridge drops modifier state (GioView.onKeyEvent // never reads event.getMetaState), so key.Event.Modifiers is always 0 and // shift+arrow is indistinguishable from a plain arrow. Gio does deliver // the NameShift press/release as plain key.Events, so we track them here // and attach the state to the ui.KeyEvent we forward. var shiftDown bool if _, err := os.Stat(filepath.Join(perfDir, "enable")); err == nil { prof = perf.New(true, perfDir, "logic_frames.csv") editor.PerfRecord = func(rec editor.ProbeRecord) { prof.Record(perf.Ctx{ Page: rec.Page, ScrollDP: rec.ScrollDP, MaxScrollDP: rec.MaxScrollDP, TotalLines: rec.TotalLines, VisStart: rec.VisStart, VisEnd: rec.VisEnd, }) } logic.EnableDebugCmdPoll(perfDir) perfOn = true log.Printf("PERF: enabled (dir=%s)", perfDir) } // The search bar's widget.Editor is owned by the MAIN goroutine: Gio // mutates it during draw, and the logic goroutine must never touch it // (architecture.md §1). Its text is forwarded to the logic goroutine via // SearchQueryChan; the logic only stores the result in Browser.Query. var searchEditor widget.Editor renderer.RegisterGioEditor("search_bar", &searchEditor) // The find bar's (in-file search) widget.Editor is main-owned the same // way; its text is forwarded via FindQueryChan (editor.findSetQuery). var findEditor widget.Editor renderer.RegisterGioEditor("find_bar", &findEditor) // Relaunch state restoration (spec §7): a tiny JSON file holds the last // file, cursor, scroll, selection and find bar state. The cmd layer owns // the file (sessionFilePath, per platform); the logic layer owns the // snapshot (editor.SessionState) and calls the saver rate-limited and at // Shutdown. Restoring re-opens the last file straight into the editor. sessPath := sessionFilePath() logic.SetSessionSaver(newSessionSaver(sessPath)) if sess, ok := loadSession(sessPath); ok { log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery) logic.BeginRestore(sess) // The find bar's input is a main-owned widget and the input source of // truth: seed it with the restored query so its first frame matches // the logic-side query (an empty widget would forward "" and clear // the restored query). if sess.FindQuery != "" { findEditor.SetText(sess.FindQuery) } } // Clipboard plumbing (architecture.md §6.3): the logic goroutine only // REQUESTS clipboard operations through channels (copy/cut write, paste // read); the main goroutine executes the Gio ops during a frame and // forwards the read result back. clipTag tags the read so its // transfer.DataEvent can be matched. A nil clipboard (e.g. a headless // test) simply never completes a read. var clipTag = struct{}{} // frame is the frame-receiver-stored handoff (architecture.md §2.2/§9): // the ONLY data the main goroutine reads from the logic side. var mu sync.Mutex var frame editor.Frame go frameReceiver(w, &mu, &frame, logic.FrameChan()) go logic.Run() // lastExcl tracks the selection-handle grab boxes last sent to // SetGestureExclusions (Android: keeps the system back gesture from // stealing drags that start on an edge handle). Only the JNI call when // the set changes — the rects move every frame while a selection is // visible/scrolling, but identical repeats are skipped. var lastExcl [][4]int // findBarFocused tracks whether the previous frame focused the find bar's // main-owned input, so main hands key focus to it exactly once per open // (see the focus handoff below). var findBarFocused bool // lastFindClearSeq is the FindClearSeq value whose wipe has already been // applied to the main-owned find input (edge-triggered, see // Frame.FindClearSeq). var lastFindClearSeq int // lastFrameW/H hold the previous FrameEvent's window size (px); 0 = no // frame yet. Used to spot shrink frames (see ZeroWheelScroll below). var lastFrameW, lastFrameH int for { e := w.Event() switch e := e.(type) { case app.DestroyEvent: if prof != nil { prof.Stop() } logic.Shutdown() return e.Err case app.ConfigEvent: // ConfigEvent: raw pixel dimensions only. logic.ConfigChan() <- editor.ConfigEvent{ PixelWidth: e.Config.Size.X, PixelHeight: e.Config.Size.Y, } case app.FrameEvent: if perfOn { presentN++ if presentStart.IsZero() { presentStart = time.Now() } if time.Since(presentStart) >= 2*time.Second { dt := time.Since(presentStart).Seconds() log.Printf("PERF-PRESENT frames=%d fps=%.1f", presentN, float64(presentN)/dt) presentN = 0 presentStart = time.Time{} } } gtx := app.NewContext(&ops, e) newScale := gtx.Metric.PxPerDp // User font-size setting: the shaper draws baselines in sp, so the // rendered line pitch in density-dp is scaled by this factor. Logic // bookkeeping (tap mapping, window start, scroll clamp) must follow // it (EffectiveLineHeight). newFontScale := float32(1) if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 { newFontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp } // Clipboard: forward a read result from an earlier frame to the // logic goroutine (one DataEvent per ReadCmd). for { evt, ok := gtx.Event(transfer.TargetFilter{Target: clipTag, Type: "application/text"}) if !ok { break } if de, ok := evt.(transfer.DataEvent); ok { body := de.Open() data, rerr := io.ReadAll(body) body.Close() if rerr == nil { logic.PasteChan() <- string(data) } } } // Clipboard: a copy/cut write requested by the logic goroutine. select { case t := <-logic.ClipboardSetChan(): gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(t))}) default: } // Clipboard: a paste request (one ReadCmd per request; the result // arrives as a DataEvent on a later frame). On Android the read is // synchronous and the resulting DataEvent is queued during this // frame's op flush — but a queued DataEvent schedules no wakeup of // its own, so invalidate to guarantee a follow-up frame in which // the loop above can consume it. select { case <-logic.PasteReqChan(): gtx.Execute(clipboard.ReadCmd{Tag: clipTag}) w.Invalidate() default: } // Read ONLY the frame-receiver-stored snapshot; the main goroutine // never touches logic State (architecture.md §1). mu.Lock() curScale := frame.Scale if curScale <= 0 { curScale = 1 // no frame yet } curFontScale := frame.FontScale // 0 until the logic has the value // App-local pinch font scale for the editor text (1.0 = default). renderer.SetAppFontScale(frame.AppFontScale) renderer.Draw(gtx, frame.Elems, curScale) glyphLayout := renderer.GlyphLayout() // Send search query update to the logic goroutine when it changes. // The logic goroutine handles filtering and triggers a new frame. newQuery := searchEditor.Text() sendQuery := newQuery != frame.Query // Mirror each NEW find-clear (the bar's X button) into the main- // owned widget input exactly once. Edge-triggered on ClearSeq, NOT // "FindQuery==\"\" && widget has text": that level check also fired on // stale frames between the user's typing and the logic storing it, // wiping the input while typing. if frame.FindClearSeq > lastFindClearSeq { lastFindClearSeq = frame.FindClearSeq findEditor.SetText("") w.Invalidate() } newFind := findEditor.Text() sendFind := newFind != frame.FindQuery // On a frame that shrinks the window (the IME opening under // adjustResize), Gio's window synthesizes a scroll-to-focus // pointer.Scroll via RevealFocus — it reads the focused field's // stale pre-resize bounds and nudges the editor content. Flag the // frame so CheckGestures drains that one synthetic event before the // scroll gesture consumes it (Renderer.ZeroWheelScroll); finger // scroll and the flinger are unaffected, and normal frames are // untouched. renderer.ZeroWheelScroll = lastFrameH > 0 && (e.Size.Y < lastFrameH || e.Size.X < lastFrameW) lastFrameW, lastFrameH = e.Size.X, e.Size.Y events := renderer.CheckGestures(e.Source, gtx.Metric) renderer.ZeroWheelScroll = false // Keep frames flowing while a long press is pending: a stationary // finger generates no pointer events, so without this the window // would sleep and the long-press threshold would never be reached. if renderer.PendingLongPress() { w.Invalidate() } // Selection-handle system-gesture exclusions (Android): forward the // frame's handle grab boxes when they changed. Runs on the UI // thread (the frame pump is), as View methods require. if excl := renderer.GestureExclusions(); !exclEqual(excl, lastExcl) { lastExcl = excl SetGestureExclusions(excl) } // Gather key events focusedID := frame.FocusedElementID // Hand key focus to the find bar's main-owned input the moment the // find bar opens: the search-icon tap never touches the widget, and // widget.Editor only grabs key focus on its own click (gioui.org/ // widget/editor.go). The widget's FocusEvent handler raises the soft // keyboard on focus gain, so the keyboard follows automatically. On // the inverse transition the editor's TextField re-issues its own // key.FocusCmd (the renderer clears the focus dedup on frames where // no logic field is focused), so nothing is needed on close. if focusedID == "find_bar" && !findBarFocused { gtx.Execute(key.FocusCmd{Tag: &findEditor}) w.Invalidate() // next frame: the widget sees the FocusEvent } findBarFocused = focusedID == "find_bar" if focusedID == "" { // No focused input: drop any stale shift state (a release event // for a key held across focus loss may never arrive). shiftDown = false } if focusedID != "" { if reg, ok := renderer.Keys[focusedID]; ok { // Use key.Filter to only receive events destined for the focused element. // We need both Key events (for arrow keys) and Edit events (for text input). // In Gio, key.Filter covers key presses, while key.FocusFilter covers // focus and text edit events. // // On mobile the window layer wraps plain arrow-key PRESSES in // input.SystemEvent (it wants to use them for focus navigation) and // such events match only filters that name the key explicitly. Querying // the four arrow names below therefore (a) makes the presses deliverable // and (b) suppresses the focus-move side effect: a matched event makes // WakeupTime report handled, which skips the window's moveFocus call. for { // Filter for key events and focus/edit events targeted at focusedID evt, ok := gtx.Event( key.Filter{Focus: focusedID}, key.Filter{Focus: focusedID, Name: key.NameLeftArrow}, key.Filter{Focus: focusedID, Name: key.NameRightArrow}, key.Filter{Focus: focusedID, Name: key.NameUpArrow}, key.Filter{Focus: focusedID, Name: key.NameDownArrow}, key.FocusFilter{Target: focusedID}, ) if !ok { break } switch k := evt.(type) { case key.Event: if k.Name == key.NameShift { // Track shift; never forward it as a content key. shiftDown = k.State == key.Press continue } if k.State == key.Press { // ui.KeyEvent carries modifier state so handlers // can distinguish shift+arrow (extend selection) // from plain arrow (move cursor). On Android the // Modifiers field is always empty, hence the shiftDown OR. events = append(events, ui.InputEvent{ Handler: reg.Handler, Data: ui.KeyEvent{ Name: k.Name, Shift: k.Modifiers.Contain(key.ModShift) || shiftDown, }, }) } case key.EditEvent: events = append(events, ui.InputEvent{ Handler: reg.Handler, Data: k, }) case key.SnippetEvent: // Handle snippet event if necessary, or ignore case key.FocusEvent: // Focus gain/loss on the key queue (emitted when key.FocusCmd is // issued, i.e. on focus transitions). Nothing to do: the logic // layer already owns focus state. default: log.Printf("unexpected event type: %T", k) } } } } e.Frame(&ops) mu.Unlock() if newScale != curScale || newFontScale != curFontScale { logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale, FontScale: newFontScale} } if len(events) > 0 { logic.InputChan() <- events } if sendQuery { logic.SearchQueryChan() <- newQuery } if sendFind { logic.FindQueryChan() <- newFind } // Skip layout feedback for frames built before the window size // was known (frame.ViewportDegenerate, set at frame-build time — // feedback delivery lags shaping by a frame, so checking the // current size here would miss them): a zero-width shape wraps // every line into many visual lines, and feeding those counts // back would poison the WrapIndex for the window's lines // (applied once, corrected only if those lines are re-shaped at a // real width — a restored scroll that moves the viewport away // never re-shapes them, and the poisoned counts then map a // legitimate scroll offset to the wrong line). if !frame.ViewportDegenerate { logic.LayoutChan() <- ui.LayoutFeedback{ GlyphLayout: glyphLayout, WindowText: frame.WindowText, WindowStartByte: frame.WindowStartByte, WindowStartLine: frame.WindowStartLine, EditSeq: frame.EditSeq, ScrollOffset: frame.ScrollOffset, } } default: handleEvent(e) } } } // exclEqual reports whether two exclusion-rect sets are identical. // newSessionSaver builds the relaunch-session file writer registered with // the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn // write is rejected by loadSession's parse on the next launch, so a plain // write is safe (no temp+rename needed). func newSessionSaver(path string) func(editor.SessionState) { return func(s editor.SessionState) { b, err := json.Marshal(s) if err != nil { log.Printf("session: marshal: %v", err) return } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { log.Printf("session: mkdir: %v", err) return } if err := os.WriteFile(path, b, 0o600); err != nil { log.Printf("session: write: %v", err) } } } // loadSession reads and sanity-checks the relaunch session file (spec §7). // ok=false when there is no session, it is corrupt, or nothing is restorable // — in which case the app starts in the browser as before. func loadSession(path string) (editor.SessionState, bool) { b, err := os.ReadFile(path) if err != nil { return editor.SessionState{}, false } var s editor.SessionState s.ScrollLine = -1 // pre-line session files lack the key; 0 would restore to the top if err := json.Unmarshal(b, &s); err != nil { return editor.SessionState{}, false } // A partial/corrupt file must not restore garbage positions. if s.Cursor < 0 { s.Cursor = 0 } if s.Scroll < 0 { s.Scroll = 0 } if s.SelStart < -1 || s.SelEnd < -1 || s.SelEnd <= s.SelStart { s.SelStart, s.SelEnd = -1, -1 } if s.FindCurByte < -1 { s.FindCurByte = -1 } if s.ScrollLine < -1 { s.ScrollLine = -1 } if s.ScrollSub < 0 { s.ScrollSub = 0 } if s.File == "" { return editor.SessionState{}, false } return s, true } func exclEqual(a, b [][4]int) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func frameReceiver(w *app.Window, mu *sync.Mutex, frame *editor.Frame, frameChan <-chan editor.Frame) { for { f := <-frameChan mu.Lock() *frame = f w.Invalidate() mu.Unlock() } }