package main import ( "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) 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) // 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() for { switch e := w.Event().(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 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 events := renderer.CheckGestures(e.Source, gtx.Metric) // 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() } // Gather key events focusedID := frame.FocusedElementID 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 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 } logic.LayoutChan() <- ui.LayoutFeedback{ GlyphLayout: glyphLayout, WindowText: frame.WindowText, WindowStartByte: frame.WindowStartByte, WindowStartLine: frame.WindowStartLine, EditSeq: frame.EditSeq, } default: handleEvent(e) } } } 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() } }