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 03 + doc reorg + Phase 6 scroll-perf/clamping
Status: v10, 2026-08-17 (Phases 03 + 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 (fontScale1) 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 {