Pad/internal/editor/frame.go
Greg Pomerantz b24aa26446 Track Android user font scale in all line-height geometry (Phase 11)
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.
2026-08-17 10:59:34 -04:00

70 lines
2.3 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
FocusedElementID string
Query string
}
// 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,
FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query,
}
}
// 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
}
}