Pad/internal/editor/frame.go
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

111 lines
4.5 KiB
Go

package editor
import (
"time"
"pad/internal/ui"
)
// Frame is the unit of handoff from the logic goroutine to the frame
// receiver. Per architecture.md §9, the frame is the ONLY cross-goroutine
// state carrier: the main goroutine must never read *State directly, it
// reads the snapshot stored here by the frame receiver (under the frame
// mutex).
//
// Elems is the computed element tree for the next draw.
// The remaining fields are the view-state snapshot that the main goroutine
// needs to route events and forward input:
//
// - Scale: current px-per-Dp, so the main goroutine can detect scale
// changes and pass the scale to the renderer's Draw call.
// - FocusedElementID: which registered element receives key/edit events.
// - Query: the search query the logic goroutine is currently filtering
// with; the main goroutine compares it against the (main-owned) search
// widget's text and forwards changes via SearchQueryChan.
type Frame struct {
Elems []ui.Element
Scale float32
FontScale float32 // user font-size setting the logic bookkeeping used
// AppFontScale is the app-local pinch font scale (1.0 = default, 0 =
// not set yet). The renderer multiplies the editor font size by it;
// FontScale is already folded into gtx.Metric (PxPerSp).
AppFontScale float32
FocusedElementID string
Query string
// FindQuery: the in-file search query the logic goroutine has processed
// (mirrors EditorState.Find.Query). Main compares it against the
// "find_bar" widget's text and forwards changes via FindQueryChan — the
// same contract as Query/searchQueryChan.
FindQuery string
// FindClearSeq mirrors EditorState.Find.ClearSeq: main wipes the widget
// input once per NEW value (the X button cleared the logic-side query).
FindClearSeq int
// ScrollOffset is the editor scroll offset this frame's elements laid
// out at, shipped with the shaped glyph layout (LayoutFeedback) so the
// logic can express layout positions in content coordinates.
ScrollOffset ui.Dp
// WindowStartByte / WindowStartLine / EditSeq: the editor window this
// frame's elements describe. The main goroutine forwards them with the
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply
// the layout's wrap counts to exactly the lines it was shaped for, and
// drop them if an edit landed in the meantime.
WindowStartByte int
WindowStartLine int // -1 when the frame has no editor window
WindowText string // the editor window this frame's text element holds
EditSeq uint64
// ViewportDegenerate is set when this frame was built before the
// window's pixel size was known (0x0). Its editor window, if shaped at
// all, was shaped at zero width: the shaper wraps every line into many
// visual lines, and feeding those counts back (LayoutFeedback) would
// poison the WrapIndex for the window's lines. The main goroutine
// drops the feedback for such frames.
ViewportDegenerate bool
}
// frameOf wraps a computed element tree with the current view-state
// snapshot. Must be called on the logic goroutine.
func (l *Logic) frameOf(elems []ui.Element) Frame {
return Frame{
Elems: elems,
Scale: l.state.scale,
FontScale: l.state.fontScale,
AppFontScale: l.state.appFontScale,
FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query,
FindQuery: l.state.Editor.Find.Query,
FindClearSeq: l.state.Editor.Find.ClearSeq,
ScrollOffset: l.state.ScrollOffset,
WindowStartByte: l.state.Editor.IMEWindowStartByte,
WindowStartLine: l.state.WindowStartLine,
WindowText: l.state.Editor.IMEWindowText,
EditSeq: l.state.Editor.EditSeq,
ViewportDegenerate: l.state.PixelWidth <= 0 || l.state.PixelHeight <= 0,
}
}
// inspectReq is a test-only request to run fn on the logic goroutine.
// It preserves the single-owner invariant (architecture.md §1): the fn
// executes on the owner, not on the caller. fn must not block on sends to
// logic channels.
type inspectReq struct {
fn func(st *State) any
resp chan any
}
// Inspect runs fn on the logic goroutine and returns its result. It is
// intended for tests; production code must use the regular channels.
func (l *Logic) Inspect(fn func(st *State) any) (any, bool) {
req := &inspectReq{fn: fn, resp: make(chan any, 1)}
select {
case l.inspectChan <- req:
case <-time.After(5 * time.Second):
return nil, false
}
select {
case v := <-req.resp:
return v, true
case <-time.After(5 * time.Second):
return nil, false
}
}