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.
140 lines
5.2 KiB
Go
140 lines
5.2 KiB
Go
package ui
|
|
|
|
import (
|
|
"gioui.org/unit"
|
|
"pad/internal/io/pool/types"
|
|
)
|
|
|
|
// Dp represents device-independent pixels. Use for all element positions,
|
|
// sizes, and spacing in the logic/layout layer.
|
|
type Dp unit.Dp
|
|
|
|
// Px represents physical device pixels. Use only when interfacing with
|
|
// Gio's layout.Context (gtx.Constraints, gtx.Dp(), etc.).
|
|
type Px int
|
|
|
|
// ToDp converts physical pixels to device-independent pixels using the
|
|
// given scale factor (pixels per DP). Typically from gtx.Metric.PxPerDp.
|
|
func ToDp(px Px, scale float32) Dp {
|
|
return Dp(float32(px) / scale)
|
|
}
|
|
|
|
// ToPx converts device-independent pixels to physical pixels using the
|
|
// given scale factor (pixels per DP).
|
|
func ToPx(dp Dp, scale float32) Px {
|
|
return Px(float32(dp) * scale)
|
|
}
|
|
|
|
// PxPerDp returns the scale factor: how many physical pixels per DP.
|
|
// Use this to convert between Dp and Px.
|
|
func PxPerDp(scale float32) float32 { return scale }
|
|
|
|
// --- Gio interop helpers ---
|
|
|
|
// DpToPx converts a gioui unit.Dp to our Px using the scale factor.
|
|
func DpToPx(d unit.Dp, scale float32) Px {
|
|
return Px(float32(d) * scale)
|
|
}
|
|
|
|
// PxToDp converts our Px to a gioui unit.Dp using the scale factor.
|
|
func PxToDp(p Px, scale float32) unit.Dp {
|
|
return unit.Dp(float32(p) / scale)
|
|
}
|
|
|
|
// RegionPx is a region in physical pixels. Used for Gio interop only.
|
|
type RegionPx struct {
|
|
X, Y Px
|
|
W, H Px
|
|
}
|
|
|
|
// ToDp converts a RegionPx to a Region (Dp) using the scale factor.
|
|
func (r RegionPx) ToDp(scale float32) Region {
|
|
return Region{
|
|
X: ToDp(r.X, scale),
|
|
Y: ToDp(r.Y, scale),
|
|
W: ToDp(r.W, scale),
|
|
H: ToDp(r.H, scale),
|
|
}
|
|
}
|
|
|
|
// FromDp converts a Region (Dp) to a RegionPx using the scale factor.
|
|
func FromDp(r Region, scale float32) RegionPx {
|
|
return RegionPx{
|
|
X: ToPx(r.X, scale),
|
|
Y: ToPx(r.Y, scale),
|
|
W: ToPx(r.W, scale),
|
|
H: ToPx(r.H, scale),
|
|
}
|
|
}
|
|
|
|
// GlyphLayout holds per-glyph layout data captured during text shaping.
|
|
// Each index i represents one glyph (one rune in the source string).
|
|
// ByteOffsets[i] is the byte position in the buffer, (X[i], Y[i]) is the
|
|
// glyph's screen location in Dp (X relative to text region origin, Y is the
|
|
// shaper baseline), and Advance[i] is the glyph's width in Dp.
|
|
// LineHeight is the shaper's actual baseline-to-baseline line height in Dp,
|
|
// derived from consecutive lines' Y values.
|
|
// LayoutFeedback carries the renderer's per-frame glyph layout back to the
|
|
// logic goroutine, together with the window the layout was shaped for.
|
|
// A scroll may move the window between shaping and delivery, so the
|
|
// correlation pass applies the layout's wrap counts to the lines THIS layout
|
|
// describes: WindowText is the exact text that was shaped (grouping the
|
|
// VisualLineStarts over the current window instead would attribute counts to
|
|
// the wrong lines whenever the window moved), and WindowStartLine is the
|
|
// logical line that text begins at. EditSeq correlates with the editor's
|
|
// content-edit counter: a feedback whose EditSeq differs from the current
|
|
// state was shaped before an edit and its counts must be dropped (an edit
|
|
// shifts lines).
|
|
type LayoutFeedback struct {
|
|
GlyphLayout GlyphLayout
|
|
WindowText string // the exact text this layout was shaped for
|
|
WindowStartByte int // absolute byte offset of the window's first byte
|
|
WindowStartLine int // logical line the window starts at (-1: none)
|
|
EditSeq uint64 // editor content-edit counter at frame time
|
|
// ScrollOffset is the editor scroll offset (Dp) the frame this layout
|
|
// was shaped from carried. The logic needs it to express layout positions
|
|
// (window-relative) in content coordinates: the window top is the
|
|
// shaped scroll's sub-line remainder above the region top.
|
|
ScrollOffset Dp
|
|
}
|
|
|
|
type GlyphLayout struct {
|
|
ByteOffsets []int // byte offset of each glyph in the buffer
|
|
X []Dp // screen X (Dp) of each glyph, relative to text region origin
|
|
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
|
|
Advance []Dp // advance width (Dp) of each glyph
|
|
LineHeight Dp // shaper's actual baseline-to-baseline line height in Dp
|
|
VisualLineStarts []int // byte offsets where each visual line starts (for word wrap)
|
|
VisualLineIndex *types.VisualLineIndex // Optional: pre-computed visual line index for this layout
|
|
}
|
|
|
|
// VisualLineOffsets returns the byte offset of each visual line start.
|
|
// Uses pre-captured VisualLineStarts if available, otherwise computes from glyph data.
|
|
func (gl GlyphLayout) VisualLineOffsets() []int32 {
|
|
if len(gl.VisualLineStarts) > 0 {
|
|
// Use pre-captured visual line starts (more accurate for word wrap)
|
|
offsets := make([]int32, len(gl.VisualLineStarts))
|
|
for i, v := range gl.VisualLineStarts {
|
|
offsets[i] = int32(v)
|
|
}
|
|
return offsets
|
|
}
|
|
|
|
// Fallback: compute from glyph data (less accurate for word wrap)
|
|
var offsets []int32
|
|
if len(gl.ByteOffsets) == 0 {
|
|
return offsets
|
|
}
|
|
|
|
// The first line always starts at byte 0
|
|
offsets = append(offsets, int32(gl.ByteOffsets[0]))
|
|
|
|
// Add byte offset for each new Y coordinate
|
|
for i := 1; i < len(gl.Y); i++ {
|
|
if gl.Y[i] != gl.Y[i-1] {
|
|
offsets = append(offsets, int32(gl.ByteOffsets[i]))
|
|
}
|
|
}
|
|
return offsets
|
|
}
|