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.
This commit is contained in:
Greg Pomerantz 2026-08-17 10:59:34 -04:00
parent e761436908
commit b24aa26446
10 changed files with 319 additions and 33 deletions

View File

@ -146,6 +146,14 @@ func run(w *app.Window) error {
}
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 {
@ -187,6 +195,7 @@ func run(w *app.Window) error {
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.
@ -269,8 +278,8 @@ func run(w *app.Window) error {
}
e.Frame(&ops)
mu.Unlock()
if newScale != curScale {
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale}
if newScale != curScale || newFontScale != curFontScale {
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale, FontScale: newFontScale}
}
if len(events) > 0 {
logic.InputChan() <- events

View File

@ -97,6 +97,30 @@ Conversions:
- screen → app-local: **Y − 128** (surface starts below the status bar; X unchanged)
- app-local px → pt: **÷ 2.625** (density 420)
There are TWO independent scale factors, not one:
- **Density** (`PxPerDp`, 2.625 here): the px↔dp conversion above. All
geometry bookkeeping is in density-dp, so the pipeline is scale-free and
holds at any display density (verified algebraically; the tap/scroll proof
in Phase 10 uses no device numbers).
- **User font scale** (`PxPerSp/PxPerDp`, e.g. `settings put system
font_scale 1.3`): scales the *text* only (the shaper draws in sp), so the
rendered line pitch is `16.8 × fontScale` dp. The logic side tracks this
via `EffectiveLineHeight()` and every line-height consumer uses it
(window start, sub-line remainder, tap mapping, scroll clamp, caret,
handles, highlight). Change it and the line pitch on screen changes
(57 px/line at 1.3× vs 44 px/line at 1.0× on this AVD).
On-device verification notes:
- The profiler CSV (`/storage/emulated/0/PadPerf/logic_frames.csv`) flushes
to disk at most every 2 s, so `tail` can be up to 2 s stale — a scroll
fling still settling reads as "settled" if two reads fall in the same
flush window. Compare rows ≥ 2.5 s apart, taken after the last input.
- The tap test is the ground truth and does not depend on the profiler:
screenshot → visually identify a line → tap it → type a marker → read the
file off the device → the marker must be on exactly the tapped line.
Full pipeline, screen → file byte (each hop has exactly one place it
happens; the app is internally consistent, so errors only appear at the
hops, not inside a space):

View File

@ -246,6 +246,19 @@ Only the visible byte range is shaped and drawn each frame:
a float64 mod still reflects the line below, so the window start and the
remainder disagree by one line in a sub-pixel-wide band of offsets and the
whole rendered window (hence every tapped line) shifts by one.
- **Font-scale axis.** The shaper draws baselines in sp, so on Android the
rendered line pitch in density-dp is `EditorLineHeight()*fontScale`
(`fontScale = Metric.PxPerSp/PxPerDp`, the user font-size setting).
Every consumer of `lh` above therefore uses `EffectiveLineHeight()` (the
font-scale-applied value, tracked in `State` via `ScaleEvent.FontScale`),
and the renderer's `GlyphLayout.LineHeight`, caret, handles, and selection
highlight use the same scaled pitch. With the raw line height, a
non-default font setting would misplace taps by up to (fontScale-1)
viewportfuls of lines and make scroll clamping stop short of (or run past)
the file ends. Density itself (pure hi-DPI) is a separate axis: all
geometry bookkeeping is in density-dp and scale enters only at the px↔dp
conversion (single `r.scale`/`State.scale`), so the invariant above is
scale-free and holds at any display density.
- **Never shape the whole file.** Lesson learned (Phase 3): the shaper's
internal `document` retains the backing array of its largest layout forever
(`reset()` keeps the cap), so one whole-file layout permanently inflated

View File

@ -1,9 +1,10 @@
# Development Plan: reach a lean, usable Android text editor
Status: v9, 2026-08-17 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping
Status: v10, 2026-08-17 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping
verification + tap-to-position-cursor fix + selection + real-file e2e +
Android arrow/shift workaround + touch selection + scroll-offset tap proof &
float32 decomposition fix). Written
float32 decomposition fix + font-scale (user font-size setting) support).
Written
against the **live** repo `/home/gmp/pad`. v1 (the widget-rebuild plan) is
superseded — see §12 for why. Doc reorganization (2026-08-16): the over-detailed
docs (`*_implementation_plan.md`, `touch.md`, `element_model.md`,
@ -454,6 +455,48 @@ now fixed and property-tested.
of a 300-line file). Screenshot, profiler ScrollDP, formula, and disk all
agreed.
### Phase 11 — hi-DPI / font-scale audit and font-scale fix — DONE (2026-08-17)
Question: does the tap/scroll mapping hold under all allowed device display
configurations, or only on this AVD's density?
- **Density (pure hi-DPI): holds, provably.** All geometry bookkeeping is in
density-dp; the device scale enters only at the px↔dp boundary via a single
`r.scale`/`State.scale` (= `PxPerDp`), with sub-pixel float32 rounding that
cannot cross a line boundary. The Phase 10 invariant uses no device numbers,
so it is scale-free: any density.
- **Font scale (user font-size setting): was broken, now fixed.** On Android,
`PxPerSp = fontScale × PxPerDp` (the Settings → font-size knob), and the
shaper draws baselines in sp — so at, say, fontScale 1.3 the rendered line
pitch is 21.84 dp while every logic-side consumer used the raw 16.8 dp.
Taps would have been off by up to (fontScale−1) viewportfuls of lines and
scroll clamping would have stopped short of the file bottom. Fixed by
tracking `fontScale` in `State` (`ScaleEvent.FontScale`, closed loop via
`Frame.FontScale`) and routing every line-height consumer through
`EffectiveLineHeight()` (window start, sub-line remainder, tap mapping,
scroll clamp, page size, cursor vertical move, menu position), with the
renderer using the same scaled pitch for `GlyphLayout.LineHeight`, the
caret, selection handles, and highlight (ascent/line-height in
`drawWrappedText` now `× fontScale` from `gtx.Metric`).
- **Proof:** `font_scale_test.go` — `TestTapPosition_FontScale_Property`
(2000 random scroll/tap pairs at fontScale 1.3 with the glyph layout
fabricated at the scaled line pitch, same independent ground truth as
Phase 10) plus an `EffectiveLineHeight` unit test. Existing tests are
unaffected (unknown fontScale ⇒ 1.0).
- **On-device cross-check:** with `settings put system font_scale 1.3` and
`0.8`, at multiple scroll positions (including a settled one where the
topmost visible line was verified to be exactly k = floor(s/lh_eff)), a tap
on a visually identified line typed a marker that landed on exactly that
line in the file on disk (fsline060, fsline080, fsline081 at 1.3×; fsline039
at 0.8×). Rendered line pitch measured 57 px at 1.3× and 35 px at 0.8× vs
44 px at 1.0× — matching 16.8×fontScale×2.625.
- **Verification-method note:** the profiler CSV flushes to disk at most every
2 s, so `tail` reads can be up to 2 s stale (a settling fling reads as
"settled" if two reads land in the same flush window). Settle checks compare
rows ≥ 2.5 s apart after the last input; the tap test (screenshot → tap →
marker → read the file off the device) is the ground truth and needs no
profiler.
## 6. File-size decision (re-framed)
v1 framed this as "accept a limit vs build a windowed editor." The live repo

View File

@ -444,7 +444,7 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
// extra wrapped lines are simply clipped by the renderer.
lineH := lineHeight
if lineH <= 0 {
lineH = EditorLineHeight()
lineH = EffectiveLineHeight()
}
if cb.LineIndex == nil {
start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineH)
@ -458,7 +458,7 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
// heuristic estimates. Used when the line index is not yet available.
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end int) {
if lineHeight <= 0 {
lineHeight = EditorLineHeight()
lineHeight = EffectiveLineHeight()
}
startLine, _ := scrollDecompose(scrollOffset, lineHeight)
@ -533,7 +533,7 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineHeight)
}
if lineHeight <= 0 {
lineHeight = EditorLineHeight()
lineHeight = EffectiveLineHeight()
}
// startLine must use the same floor decomposition as the renderer's

View File

@ -0,0 +1,141 @@
package editor
import (
"fmt"
"math"
"math/rand"
"testing"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
// These tests prove the tap/scroll bookkeeping follows the RENDERED line
// pitch under a non-default user font-size setting. On Android,
// Metric.PxPerSp = fontScale * PxPerDp, and the shaper draws baselines at
// Sp(fontSize*LineHeightScale) physical px, so the rendered line pitch in
// density-dp is EditorLineHeight()*fontScale. Every consumer must use
// EffectiveLineHeight (window start, sub-line remainder, tap mapping, scroll
// clamp); at fontScale != 1 the raw EditorLineHeight would misplace taps by
// up to (fontScale-1) viewportfuls of lines.
func TestEffectiveLineHeight_FollowsFontScale(t *testing.T) {
TheState = NewState()
defer func() { TheState.fontScale = 0 }()
cases := []struct {
fs float32
want float64
}{
{0, float64(EditorLineHeight())}, // unknown -> 1.0
{1, float64(EditorLineHeight())}, // default
{1.3, float64(EditorLineHeight()) * 1.3},
{0.8, float64(EditorLineHeight()) * 0.8},
}
for _, c := range cases {
TheState.fontScale = c.fs
got := float64(EffectiveLineHeight())
if math.Abs(got-c.want) > 1e-5 { // float32 ui.Dp rounding
t.Errorf("fontScale=%v: EffectiveLineHeight=%v want %v", c.fs, got, c.want)
}
}
}
// Generalizes TestTapPosition_ScrollInvariant_Property to fontScale=1.3:
// the windowed layout is fabricated the way the shaper produces it with the
// scaled line pitch (baseline of display line j = (j+1)*lh, lh already
// font-scaled), and the tapped line must equal the drawn line under the
// finger for random scroll offsets and finger positions.
func TestTapPosition_FontScale_Property(t *testing.T) {
const (
lines = 400
bytesPerLine = 5
regionTop = float64(62)
viewportH = float64(760)
fs = float32(1.3)
)
var file string
for i := 0; i < lines; i++ {
file += fmt.Sprintf("L%03d\n", i)
}
cb := NewChunkedBuffer("/fs.txt", DefaultChunkSize, nil, "")
cb.SetContent([]byte(file))
offsets := []int32{0}
for i := 0; i < len(file); i++ {
if file[i] == '\n' {
offsets = append(offsets, int32(i+1))
}
}
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(file)))
lh := float64(EffectiveLineHeightAt(fs))
if math.Abs(lh-float64(EditorLineHeight())*float64(fs)) > 1e-5 { // float32 ui.Dp rounding
t.Fatalf("effective line height %v != %v*%v", lh, float64(EditorLineHeight()), fs)
}
rng := rand.New(rand.NewSource(7))
maxS := float64(lines) * lh
for tc := 0; tc < 2000; tc++ {
var s float64
switch tc % 4 {
case 0:
s = rng.Float64() * maxS
case 1:
s = float64(int(rng.Float64() * maxS))
case 2:
s = float64(rng.Intn(lines)) * lh
case 3:
s = float64(rng.Intn(lines))*lh + rng.Float64()*(lh-1)
}
sOff := ui.Dp(s)
// What layoutFrame/visibleByteRangePrecise does with the effective
// line height: window starts at content line k = floor(s/lh).
start, _, _ := cb.VisibleByteRange(sOff, 0, ui.Dp(viewportH), EffectiveLineHeightAt(fs), false, ui.GlyphLayout{}, nil)
if start%bytesPerLine != 0 {
t.Fatalf("test setup: window start %d not on a line boundary", start)
}
k := start / bytesPerLine
// Fabricate the windowed GlyphLayout exactly as the shaper would
// with the scaled line pitch: display line j = content line k+j.
nWin := int(math.Min(viewportH/lh+2, float64(lines-k)))
var gl ui.GlyphLayout
gl.LineHeight = EffectiveLineHeightAt(fs)
for j := 0; j < nWin; j++ {
gl.ByteOffsets = append(gl.ByteOffsets, j*bytesPerLine)
gl.X = append(gl.X, 10)
gl.Y = append(gl.Y, ui.Dp(float64(j+1)*lh))
gl.Advance = append(gl.Advance, 10)
}
TheState = NewState()
TheState.fontScale = fs
TheState.Editor.ChunkedBuffer = cb
TheState.Editor.GlyphLayout = gl
TheState.Editor.IMEWindowStartByte = start
TheState.ScrollOffset = sOff
TheState.EditorRegion = ui.Region{X: 10, Y: ui.Dp(regionTop), W: 390, H: ui.Dp(viewportH)}
a := rng.Float64() * viewportH // tap y relative to region top
HandleTapAt(15, ui.Dp(regionTop+a))
// Independent ground truth: content_y = a + s -> content line.
wantLine := int(math.Floor((a + s) / lh))
if wantLine >= lines {
wantLine = lines - 1
}
if k+nWin-1 < wantLine {
wantLine = k + nWin - 1
}
got := TheState.Editor.CursorPosition
wantLo, wantHi := wantLine*bytesPerLine, wantLine*bytesPerLine+bytesPerLine
if got < wantLo || got >= wantHi {
t.Fatalf("fs=%v s=%.2f (k=%d) tap a=%.2f: cursor=%d, want line %d [bytes %d,%d)",
fs, s, k, a, got, wantLine, wantLo, wantHi)
}
}
}

View File

@ -25,6 +25,7 @@ import (
type Frame struct {
Elems []ui.Element
Scale float32
FontScale float32 // user font-size setting the logic bookkeeping used
FocusedElementID string
Query string
}
@ -35,6 +36,7 @@ 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,
}

View File

@ -26,6 +26,11 @@ type ConfigEvent struct {
// ScaleEvent represents a metric change (HiDPI scale factor).
type ScaleEvent struct {
Scale float32
// FontScale is the user font-size setting (Metric.PxPerSp / PxPerDp).
// The shaper draws baselines in sp, so the rendered line pitch in
// density-dp is EditorLineHeight()*FontScale; all geometry bookkeeping
// follows it (see EffectiveLineHeight). 0 = unknown, treated as 1.0.
FontScale float32
}
// ConfigUpdate is a common interface for all configuration updates.
@ -41,6 +46,7 @@ func (e ConfigEvent) apply(s *State) {
func (e ScaleEvent) apply(s *State) {
s.SetScale(e.Scale)
s.SetFontScale(e.FontScale)
}
// ResultEvent represents a completed async task result.

View File

@ -157,7 +157,8 @@ type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
page Page // current page (Browser or Editor)
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
page Page // current page (Browser or Editor)
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
ByteOffset int // Byte offset of the first visible line
@ -217,10 +218,45 @@ func (s *State) SetScale(scale float32) {
s.scale = scale
}
func (s *State) SetFontScale(fs float32) {
s.fontScale = fs
}
func (s *State) Scale() float32 {
return s.scale
}
// stateFontScale returns the user font-size setting (1.0 when unknown).
func stateFontScale() float32 {
if TheState != nil && TheState.fontScale > 0 {
return TheState.fontScale
}
return 1
}
// EffectiveLineHeight is the editor line height in density-dp WITH the user
// font-size setting applied. The shaper draws baselines at
// Sp(EditorFontSize*LineHeightScale) physical px, which is
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
// clamping, cursor vertical move) must use this value rather than the raw
// EditorLineHeight; at a non-default font setting the two differ by the
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
// of lines and make scroll clamping stop short of (or run past) the file
// ends.
func EffectiveLineHeight() ui.Dp {
return EffectiveLineHeightAt(stateFontScale())
}
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
// (used where the live State value is not the right source, e.g. tests).
func EffectiveLineHeightAt(fs float32) ui.Dp {
if fs <= 0 {
fs = 1
}
return ui.Dp(float64(EditorLineHeight()) * float64(fs))
}
// layout converts stored pixel dimensions to Dp using the current scale
// and computes the element tree. Called only when a frame is needed.
// Search query sync is handled by the logic goroutine via searchQueryChan,
@ -750,7 +786,7 @@ func showSelectionMenu() {
if winW > 0 && mx+float64(menuW) > winW-8 {
mx = winW - float64(menuW) - 8
}
my := lineTop + float64(EditorLineHeight()) + 8 // below the line
my := lineTop + float64(EffectiveLineHeight()) + 8 // below the line
if winH > 0 && my+float64(menuH) > winH-8 {
my = lineTop - float64(menuH) - 8 // flip above the line
}
@ -778,7 +814,7 @@ func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
if pos < 0 || pos > windowLen {
return 0, 0, false
}
lineHeight := float64(EditorLineHeight())
lineHeight := float64(EffectiveLineHeight())
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
@ -1120,8 +1156,8 @@ func HandleEnd() {
func HandlePageUpDown(up bool) {
// For now, simple scrolling. Cursor movement could be added later.
pageSize := TheState.MaxScroll / 4 // Or some fraction
if pageSize < EditorLineHeight() {
pageSize = EditorLineHeight()
if pageSize < EffectiveLineHeight() {
pageSize = EffectiveLineHeight()
}
if up {
@ -1573,16 +1609,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
if li := cb.LineIndex; li != nil {
totalLines := li.LineCount()
maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2
maxScroll = ui.Dp(totalLines)*EffectiveLineHeight() - editorRegion.H + EffectiveLineHeight()/2
} else {
// If index is not yet built, allow scrolling beyond estimate.
// Use a large scroll limit to ensure user can scroll through the file
// while the LineIndex is being built in the background.
maxScroll = ui.Dp(1000000) * EditorLineHeight()
maxScroll = ui.Dp(1000000) * EffectiveLineHeight()
}
} else {
// Fallback for full buffer
maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
maxScroll = TheState.LastLineY - editorRegion.H + EffectiveLineHeight()/2
}
if maxScroll < 0 {
@ -1617,7 +1653,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
visibleScrollOffset = 0
} else if cb != nil {
viewportHeight := editorRegion.H
lineHeight := EditorLineHeight()
lineHeight := EffectiveLineHeight()
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
lineHeight = lh
}
@ -1663,7 +1699,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
}
} else {
// Estimate
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EditorLineHeight())
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EffectiveLineHeight())
scrollByteOffset = prefetchLine * 50
}
@ -1800,10 +1836,11 @@ func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
}
func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
// Same line height the renderer used to shape the window, and the same
// floor decomposition as the window start, so the tap maps to the drawn
// geometry for every scroll offset.
lh := EditorLineHeight()
// Same line height the renderer used to shape the window (font-scale
// aware), and the same floor decomposition as the window start, so the
// tap maps to the drawn geometry for every scroll offset and font
// setting.
lh := EffectiveLineHeight()
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
@ -1833,7 +1870,7 @@ func textPosFromLocalPoint(x, y float64) (int, bool) {
}
base := glyphBase()
lineHeight := float64(EditorLineHeight())
lineHeight := float64(EffectiveLineHeight())
// 1. Identify the intended line index based on y
// layout.Y values are relative to the text region origin.

View File

@ -655,8 +655,20 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
return
}
// Fixed line height based on font size, not glyph metrics
// Fixed line height based on font size, not glyph metrics.
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
// User font-size setting (sp per dp). The shaper draws baselines at
// Sp(...) physical px, so the RENDERED line pitch in density-dp is
// lineHeightSp × fontScale. Every dp-space value below (line height,
// ascent) uses the scaled form so caret/handles/highlight follow the
// drawn glyphs; the logic side tracks the same factor via
// EffectiveLineHeight (ScaleEvent.FontScale).
fontScale := float32(1)
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
}
ascent := Dp(float32(r.theme.FontSize) * fontScale)
lineH := Dp(float32(lineHeightSp) * fontScale)
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
@ -691,7 +703,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
// Capture per-glyph layout data for cursor positioning and navigation.
var layout GlyphLayout
layout.LineHeight = Dp(float32(lineHeightSp))
layout.LineHeight = lineH
byteOffset := 0
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
flushLine := func() {
@ -740,9 +752,9 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
continue
}
hx := reg.X + layout.X[i]
hy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize)
hy := reg.Y - scrollOffset + layout.Y[i] - ascent
hw := layout.Advance[i]
hh := Dp(r.theme.FontSize) * 1.2
hh := lineH
rect := clip.Rect{
Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))},
Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))},
@ -772,10 +784,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
})
if idx < len(layout.ByteOffsets) {
cursorX = reg.X + layout.X[idx]
cursorY = reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
cursorY = reg.Y - scrollOffset + layout.Y[idx] - ascent
} else {
cursorX = reg.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1]
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize)
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - ascent
}
}
@ -784,7 +796,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
X: cursorX,
Y: cursorY,
W: Dp(2),
H: Dp(r.theme.FontSize) * 1.2, // Use line height
H: lineH, // line height (font-scale aware)
}
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
@ -795,7 +807,6 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
event.Op(gtx.Ops, r.pressProbe)
r.longPressID = "editor_text"
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
lineH := Dp(r.theme.FontSize) * 1.2
// handleAt mirrors the cursor computation above: window-relative byte
// offset -> screen Dp of the caret insertion point.
handleAt := func(byteOff int) (x, y Dp) {
@ -803,10 +814,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
return layout.ByteOffsets[i] >= byteOff
})
if idx < len(layout.ByteOffsets) {
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - ascent
}
n := len(layout.X) - 1
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - Dp(r.theme.FontSize)
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - ascent
}
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
hc := clip.Rect{
@ -837,7 +848,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
continue
}
gx := reg.X + layout.X[i]
gy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize)
gy := reg.Y - scrollOffset + layout.Y[i] - ascent
gw := layout.Advance[i]
gh := lineH
if !hasGlyph {