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.
111 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|