diff --git a/cmd/pad/main.go b/cmd/pad/main.go index ab048cb..60dec92 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/doc/README.md b/doc/README.md index 11f6612..68e1ed4 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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): diff --git a/doc/architecture.md b/doc/architecture.md index a7456fa..f94c3f6 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/development_plan.md b/doc/development_plan.md index 2453893..2b0f032 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -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 diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 4ba088b..0de7bfb 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -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 diff --git a/internal/editor/font_scale_test.go b/internal/editor/font_scale_test.go new file mode 100644 index 0000000..0d6114e --- /dev/null +++ b/internal/editor/font_scale_test.go @@ -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) + } + } +} diff --git a/internal/editor/frame.go b/internal/editor/frame.go index feba2c4..aced968 100644 --- a/internal/editor/frame.go +++ b/internal/editor/frame.go @@ -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, } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 27dd0da..9656bd5 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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. diff --git a/internal/editor/state.go b/internal/editor/state.go index da69ce3..b714d78 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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. diff --git a/internal/ui/render.go b/internal/ui/render.go index 1c023df..e15f859 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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 {