package editor import ( "math" "testing" "pad/internal/ui" ) // These tests cover the app-local pinch font scale: EffectiveLineHeight must // follow the product of the system font scale and the app scale, and the // pinch handler must apply the relative factor continuously (no rounding to // whole points) while keeping the viewport top anchored (scroll offset // rescaled in lockstep with the line height). func TestEffectiveLineHeight_FollowsAppFontScale(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 }() cases := []struct { sys, app float32 want float64 }{ {0, 1.0, float64(EditorLineHeight())}, // both unknown/default {1.3, 1.0, float64(EditorLineHeight()) * 1.3}, {1.0, 1.7, float64(EditorLineHeight()) * 1.7}, {1.3, 1.7, float64(EditorLineHeight()) * 1.3 * 1.7}, {1.0, 0.5, float64(EditorLineHeight()) * 0.5}, } for _, c := range cases { TheState.fontScale = c.sys TheState.appFontScale = c.app got := float64(EffectiveLineHeight()) if math.Abs(got-c.want) > 1e-5 { t.Errorf("sys=%v app=%v: EffectiveLineHeight=%v want %v", c.sys, c.app, got, c.want) } } } func TestHandleFontPinch_ContinuousAndAnchored(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }() // Start scrolled to a line boundary: 100 lines at the default pitch. lh := float64(EffectiveLineHeight()) TheState.ScrollOffset = ui.Dp(100 * lh) // A sequence of small relative factors: the product is 1.1^5 ~ 1.61051, // a non-representable float32 — rounding to whole points (or to any // fixed step) would not land here. for i := 0; i < 5; i++ { HandleFontPinch(ui.FontPinchEvent{Scale: 1.1}) } want := float64(1.1) * 1.1 * 1.1 * 1.1 * 1.1 if math.Abs(float64(TheState.appFontScale)-want) > 1e-6 { t.Errorf("appFontScale=%v want %v (continuous, no snapping)", TheState.appFontScale, want) } // The scroll offset rescaled by the same ratio: the same line (100) at // the same sub-line fraction (0) stays on top. wantOff := 100 * lh * want if got := float64(TheState.ScrollOffset); math.Abs(got-wantOff) > 1e-3 { t.Errorf("ScrollOffset=%v want %v (anchor preserved)", got, wantOff) } // EffectiveLineHeight follows the new scale. wantLH := lh * want if got := float64(EffectiveLineHeight()); math.Abs(got-wantLH) > 1e-5 { t.Errorf("EffectiveLineHeight=%v want %v", got, wantLH) } } func TestHandleFontPinch_Clamps(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }() // Zoom out past the minimum. HandleFontPinch(ui.FontPinchEvent{Scale: 0.1}) if TheState.appFontScale != MinAppFontScale { t.Errorf("appFontScale=%v want MinAppFontScale %v", TheState.appFontScale, MinAppFontScale) } // Zoom in past the maximum. HandleFontPinch(ui.FontPinchEvent{Scale: 100}) if TheState.appFontScale != MaxAppFontScale { t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale) } // At the maximum, further zoom-in is a no-op (including the scroll). TheState.ScrollOffset = ui.Dp(123) HandleFontPinch(ui.FontPinchEvent{Scale: 1.5}) if TheState.appFontScale != MaxAppFontScale { t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale) } if float64(TheState.ScrollOffset) != 123 { t.Errorf("ScrollOffset=%v want 123 (unchanged at the clamp)", TheState.ScrollOffset) } } func TestSetAppFontScale_Absolute(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }() lh := float64(EffectiveLineHeight()) TheState.ScrollOffset = ui.Dp(50 * lh) SetAppFontScale(2.0) if TheState.appFontScale != 2.0 { t.Fatalf("appFontScale=%v want 2.0", TheState.appFontScale) } if got, want := float64(TheState.ScrollOffset), 50*lh*2.0; math.Abs(got-want) > 1e-3 { t.Errorf("ScrollOffset=%v want %v", got, want) } SetAppFontScale(0) // clamps to the minimum if TheState.appFontScale != MinAppFontScale { t.Errorf("appFontScale=%v want MinAppFontScale", TheState.appFontScale) } } func TestHandleFontPinch_IgnoresBadData(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 }() HandleFontPinch("not an event") HandleFontPinch(ui.FontPinchEvent{Scale: 0}) HandleFontPinch(ui.FontPinchEvent{Scale: -2}) if TheState.appFontScale != 1.0 { t.Errorf("appFontScale=%v want unchanged 1.0", TheState.appFontScale) } } // The pinch CENTER stays fixed: the continuous content coordinate under the // center (display-line units) is invariant through the scale change. A // top-anchored zoom would move that coordinate by m*(1-ratio) display // lines — for a center 400dp below the region top at ×1.5 that is ~59 // display lines off, which is exactly what this test rejects. func TestHandleFontPinch_CenterAnchor(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 TheState.ScrollOffset = 0 TheState.EditorRegion = ui.Region{} }() TheState.EditorRegion = ui.Region{Y: 100, H: 800} lh := float64(EffectiveLineHeight()) TheState.ScrollOffset = ui.Dp(100 * lh) // Center 400dp below the region top. const m = 400.0 wantU := (float64(TheState.ScrollOffset) + m) / lh // content coord under the center HandleFontPinch(ui.FontPinchEvent{Scale: 1.5, Center: ui.Point{Y: 100 + m}}) lhNew := float64(EffectiveLineHeight()) gotU := (float64(TheState.ScrollOffset) + m) / lhNew // Tolerance is float32-scale + ui.Dp rounding accumulated over two // conversions — still 4 orders of magnitude smaller than the ~59-line // drift a top-anchored zoom would produce here. if math.Abs(gotU-wantU) > 1e-4 { t.Errorf("content under center: u=%v want %v (center must stay fixed)", gotU, wantU) } // Top-anchor sanity: the TOP moved (that is the point of center anchoring). if float64(TheState.ScrollOffset) == 100*lh*1.5 { t.Errorf("scroll=%v equals the top-anchored value; the center was not the anchor", TheState.ScrollOffset) } } // With word wrap, the anchor must be the LOGICAL line under the center, not // the display line: when the font change re-wraps the lines above the // anchor (their fragment counts grow), the display-line index under the // center changes, but the same logical line / fragment / sub-line must stay // under it. applyFontPin is what the logic re-runs as rewrap corrections // land (the font-pin). func TestFontPin_SurvivesRewrap(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 TheState.ScrollOffset = 0 TheState.Editor.ChunkedBuffer = nil }() // 1000 logical lines, each wrapped into 2 fragments at the current size. w := NewWrapIndex(1000) for i := 0; i < 1000; i++ { w.Set(i, 2) } TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w} lh := float64(EffectiveLineHeight()) const m = 200.0 // region top (EditorRegion zero) + 200dp // Anchor: logical line 60, its 1st fragment, 0.5 down it. TheState.ScrollOffset = ui.Dp(float64(w.VisualsBefore(60))*lh + 0.5*lh - m) line, frag, sub, ok := TheState.captureFontPin(m) if !ok || line != 60 || frag != 0 || math.Abs(sub-0.5) > 1e-5 { t.Fatalf("capture: line=%d frag=%d sub=%v ok=%v, want line 60 frag 0 sub 0.5", line, frag, sub, ok) } // The font grows ×1.5 (the line under the fingers keeps its 2 fragments // for now): the same point stays under m. TheState.appFontScale = 1.5 TheState.applyFontPin(line, frag, sub, m) lhNew := float64(EffectiveLineHeight()) u := (float64(TheState.ScrollOffset) + m) / lhNew if got := int(w.LineForVisual(int32(u))); got != 60 { t.Errorf("after scale: line under center=%d want 60", got) } // Rewrap lands: every line now wraps into 3 fragments. The display-line // index under the center moves from 120.5 to 180.5, but re-applying the // pin must keep logical line 60 (fragment 0, sub 0.5) under the center. for i := 0; i < 1000; i++ { w.Set(i, 3) } TheState.applyFontPin(line, frag, sub, m) u = (float64(TheState.ScrollOffset) + m) / lhNew if got := int(w.LineForVisual(int32(u))); got != 60 { t.Errorf("after rewrap: line under center=%d want 60 (display-line anchoring would fail here)", got) } // And a display-line anchor (what NOT to do) would be off: at the new // counts, display line 120.5 is logical line 40, not 60. if got := int(w.LineForVisual(120)); got == 60 { t.Errorf("test is stale: display line 120 unexpectedly maps to line 60") } } // fabLayout builds a GlyphLayout the way the shaper produces one: glyphsPerFrag // glyphs per fragment, baseline of fragment f at (f+0.8)*lh (ascent < lh, the // shaper's convention), 20dp advance each. func fabLayout(lh float64, fragments, glyphsPerFrag int, byte0 int) ui.GlyphLayout { gl := ui.GlyphLayout{LineHeight: ui.Dp(lh)} for f := 0; f < fragments; f++ { for g := 0; g < glyphsPerFrag; g++ { gl.ByteOffsets = append(gl.ByteOffsets, byte0+f*glyphsPerFrag+g) gl.X = append(gl.X, ui.Dp(10+20*float64(g))) gl.Y = append(gl.Y, ui.Dp((float64(f)+0.8)*lh)) gl.Advance = append(gl.Advance, ui.Dp(20)) } } return gl } func TestGlyphAtLocalPoint(t *testing.T) { gl := fabLayout(16.8, 2, 4, 0) // bytes 0-3 on fragment 0, 4-7 on fragment 1 if i, ok := glyphAtLocalPoint(gl, 50, 25); !ok || i != 6 { t.Errorf("(50,25): i=%d ok=%v, want glyph 6 (byte 6, X=50 on fragment 1)", i, ok) } if _, ok := glyphAtLocalPoint(gl, 5, 25); ok { t.Error("(5,25): left margin has no glyph, want not-ok") } if i, ok := glyphAtLocalPoint(gl, 1000, 25); !ok || i != 7 { t.Errorf("(1000,25): past the line end pins its last glyph, got i=%d ok=%v want 7", i, ok) } if _, ok := glyphAtLocalPoint(ui.GlyphLayout{}, 50, 25); ok { t.Error("empty layout: want not-ok") } } func TestCaptureContentPin(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 TheState.ScrollOffset = 0 TheState.Editor.GlyphLayout = ui.GlyphLayout{} }() TheState.Editor.GlyphLayout = fabLayout(16.8, 2, 4, 0) TheState.ScrollOffset = 0 // Point at (x=50, y=25): fragment 1 (baseline 30.24), the glyph at X=50. pin := TheState.captureContentPin(50, 25) if !pin.HaveGlyph || pin.Byte != 6 { t.Fatalf("pin: byte=%d haveGlyph=%v, want byte 6", pin.Byte, pin.HaveGlyph) } if math.Abs(pin.Dy-(25-30.24)) > 1e-5 { // ui.Dp stores float32-precision values t.Errorf("dy=%v want %v (offset from the glyph baseline)", pin.Dy, 25-30.24) } // Fallback anchor (no WrapIndex): line 1, sub = 25/16.8 - 1 ~ 0.488. if pin.Line != 1 || pin.Frag != 0 || math.Abs(pin.Sub-(25/16.8-1)) > 1e-6 { t.Errorf("fallback: line=%d frag=%d sub=%v, want line 1 frag 0 sub %v", pin.Line, pin.Frag, pin.Sub, 25/16.8-1) } } // The REFINEMENT is what makes the content point stay fixed across a rewrap: // the pinned byte moves to a different fragment in the new layout, and the // refined offset places that byte's baseline (plus the captured dy) exactly // at the pinch center — where the (line, fragment) anchor would leave a // different character there. func TestRefineContentPin_RewrapKeepsContentPoint(t *testing.T) { const m = 25.0 // region-relative pinch center const dy = 25.0 - 30.24 // from TestCaptureContentPin's point (above baseline) // OLD layout (font 1.0x): byte 6 on fragment 1. (Capture would have // returned Byte=6, Dy=dy.) _ = fabLayout(16.8, 2, 4, 0) // Font grows to 1.5x: the line re-wraps to 3 fragments x 3 glyphs; byte 6 // lands on fragment 2 (a DIFFERENT fragment than the old fragment 1 it // was captured on... old frag index was 1, new is 2). glNew := fabLayout(25.2, 3, 3, 0) // The new layout is shaped for the window starting at document byte 0, // whose first visual line is visual line 3 of the document (vk=3): // the window top sits at content 3*25.2. vk := 3 off, ok := refineContentPin(glNew, vk, 0, dy, m, 6) if !ok { t.Fatal("refine: want ok") } // The pinned point (byte 6's baseline + dy) must sit exactly at m. contentY := float64(off) + m // content coordinate of the region-relative m // byte 6's baseline: window y = (2+0.8)*25.2, window top at content vk*lh. baselineContent := float64(vk)*float64(glNew.LineHeight) + float64(glNew.Y[6]) if math.Abs(baselineContent+dy-contentY) > 1e-5 { // float32-precision layout Y t.Errorf("pinned point at content %v, want %v (baseline %v + dy)", contentY, baselineContent+dy, baselineContent) } // (A (line, fragment) anchor would have failed here: the byte captured on // the old fragment 1 sits on the NEW fragment 2, a full line further down. // Pinning the old fragment index would leave a different character at the // center — exactly the drift the content pin exists to remove.) } // Steady state: a layout shaped at offset S with the pinned point at the // center must refine back to exactly S (the fixed point — no drift, no // oscillation while the pin is armed). func TestRefineContentPin_FixedPoint(t *testing.T) { const m = 25.0 lh := ui.Dp(25.2) S := ui.Dp(100) vk, r := scrollDecompose(S, lh) // window's first visual line = v0 (no wrap) const dy = 3.0 // Place byte 4 at window y = m + r - dy so the point sits at m when the // window (top at content vk*lh) is shaped at S. wantY := m + r - dy gl := ui.GlyphLayout{LineHeight: lh} for b := 0; b < 6; b++ { gl.ByteOffsets = append(gl.ByteOffsets, b) gl.X = append(gl.X, ui.Dp(10+20*float64(b%3))) gl.Y = append(gl.Y, ui.Dp(wantY)) // one line's worth for this test gl.Advance = append(gl.Advance, ui.Dp(20)) } off, ok := refineContentPin(gl, int(vk), 0, dy, m, 4) if !ok { t.Fatal("refine: want ok") } if math.Abs(float64(off)-float64(S)) > 1e-9 { t.Errorf("fixed point: refine(S)=%v want S=%v (no drift)", off, S) } } // Pinch-out clamp of the pinned fragment: a line that re-wraps to FEWER // fragments must not pin a fragment that no longer exists. func TestFontPin_FragmentClampedOnPinchOut(t *testing.T) { TheLogic = nil TheState = NewState() defer func() { TheState.appFontScale = 1.0 TheState.ScrollOffset = 0 TheState.Editor.ChunkedBuffer = nil }() w := NewWrapIndex(100) for i := 0; i < 100; i++ { w.Set(i, 3) } TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w} // Pin line 5, fragment 2 (the third fragment). TheState.appFontScale = 1.0 const m = 100.0 TheState.applyFontPin(5, 2, 0.25, m) // Pinch out: the line now wraps into 2 fragments. for i := 0; i < 100; i++ { w.Set(i, 2) } TheState.appFontScale = 0.8 TheState.applyFontPin(5, 2, 0.25, m) // frag 2 must clamp to 1 lh := float64(EffectiveLineHeight()) u := (float64(TheState.ScrollOffset) + m) / lh // The anchor is now line 5, its LAST fragment (index 1), 0.25 down it. wantU := float64(w.VisualsBefore(5)+1) + 0.25 if math.Abs(u-wantU) > 1e-6 { t.Errorf("anchor u=%v want %v (last remaining fragment of line 5)", u, wantU) } }