Pad/cmd/pad/main.go
Greg Pomerantz 83f7affee9 Fix word-wrap scroll jump: visual-line mapping via WrapIndex
Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.

- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
  per-logical-line visual-line counts, parallel to the LineIndex;
  built at index-build time, bookkept by the same
  UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
  touched line resets to the estimate, the next shaping pass
  re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
  k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
  go through it, so the viewport top is always exactly s into the
  document's visual space (V(k)*lh + r = s) — the jump invariant.
  All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
  non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
  grouped per logical line and written back (applyWrapCounts). The
  layout feedback now carries the exact window text the layout was
  shaped for (carried in the frame) plus the window start line and the
  content-edit counter; corrections apply only on edit-counter match,
  and grouping over the current window text (wrong after a scroll moved
  the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
  pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
  height; the bottom clamp lands exactly on the file end for wrapped
  content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
  instead of dropping it — a dropped final frame was never re-emitted
  (emission is event-driven), leaving the consumer one state behind
  forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
  failure. Still non-blocking.

Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.

On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.

Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
2026-08-17 19:46:47 -04:00

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