Density (pure hi-DPI) was already scale-free: all bookkeeping is in density-dp and the scale enters only at the px<->dp boundary. But Android also has a second axis, the user font-size setting (PxPerSp = fontScale * PxPerDp), and the shaper draws baselines in sp. At a non-default font scale the rendered line pitch is 16.8*fontScale dp while every logic-side consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1) viewportfuls of lines and scroll clamping would stop short of the bottom. - ScaleEvent.FontScale + Frame.FontScale closed loop (main reads gtx.Metric, logic tracks it in State.fontScale). - EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied line height, now used by every consumer (window start, sub-line remainder, tap mapping, scroll clamp, page size, cursor vertical move, menu position, chunk-prefetch fallbacks). - Renderer: GlyphLayout.LineHeight, caret, selection handles, and highlight all use the scaled ascent/line-height from gtx.Metric. - font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with the glyph layout fabricated at the scaled pitch (independent ground truth), plus EffectiveLineHeight unit test. - On-device: tap markers landed on exactly the tapped line at font_scale 1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured 57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position window start k = floor(s/lh_eff) verified against the visible top line. - Docs: architecture.md 6.2 font-scale axis, README two-scale note + profiler 2s flush staleness note, development plan v10 Phase 11.
306 lines
10 KiB
Go
306 lines
10 KiB
Go
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() <- glyphLayout
|
|
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()
|
|
}
|
|
}
|