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:
parent
e761436908
commit
b24aa26446
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
141
internal/editor/font_scale_test.go
Normal file
141
internal/editor/font_scale_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user