Pad/internal/ui/unit.go
Greg Pomerantz 83f7affee9 Fix word-wrap scroll jump: visual-line mapping via WrapIndex
Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.

- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
  per-logical-line visual-line counts, parallel to the LineIndex;
  built at index-build time, bookkept by the same
  UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
  touched line resets to the estimate, the next shaping pass
  re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
  k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
  go through it, so the viewport top is always exactly s into the
  document's visual space (V(k)*lh + r = s) — the jump invariant.
  All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
  non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
  grouped per logical line and written back (applyWrapCounts). The
  layout feedback now carries the exact window text the layout was
  shaped for (carried in the frame) plus the window start line and the
  content-edit counter; corrections apply only on edit-counter match,
  and grouping over the current window text (wrong after a scroll moved
  the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
  pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
  height; the bottom clamp lands exactly on the file end for wrapped
  content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
  instead of dropping it — a dropped final frame was never re-emitted
  (emission is event-driven), leaving the consumer one state behind
  forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
  failure. Still non-blocking.

Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.

On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.

Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
2026-08-17 19:46:47 -04:00

135 lines
4.9 KiB
Go

package ui
import (
"gioui.org/unit"
"pad/internal/io/pool/types"
)
// Dp represents device-independent pixels. Use for all element positions,
// sizes, and spacing in the logic/layout layer.
type Dp unit.Dp
// Px represents physical device pixels. Use only when interfacing with
// Gio's layout.Context (gtx.Constraints, gtx.Dp(), etc.).
type Px int
// ToDp converts physical pixels to device-independent pixels using the
// given scale factor (pixels per DP). Typically from gtx.Metric.PxPerDp.
func ToDp(px Px, scale float32) Dp {
return Dp(float32(px) / scale)
}
// ToPx converts device-independent pixels to physical pixels using the
// given scale factor (pixels per DP).
func ToPx(dp Dp, scale float32) Px {
return Px(float32(dp) * scale)
}
// PxPerDp returns the scale factor: how many physical pixels per DP.
// Use this to convert between Dp and Px.
func PxPerDp(scale float32) float32 { return scale }
// --- Gio interop helpers ---
// DpToPx converts a gioui unit.Dp to our Px using the scale factor.
func DpToPx(d unit.Dp, scale float32) Px {
return Px(float32(d) * scale)
}
// PxToDp converts our Px to a gioui unit.Dp using the scale factor.
func PxToDp(p Px, scale float32) unit.Dp {
return unit.Dp(float32(p) / scale)
}
// RegionPx is a region in physical pixels. Used for Gio interop only.
type RegionPx struct {
X, Y Px
W, H Px
}
// ToDp converts a RegionPx to a Region (Dp) using the scale factor.
func (r RegionPx) ToDp(scale float32) Region {
return Region{
X: ToDp(r.X, scale),
Y: ToDp(r.Y, scale),
W: ToDp(r.W, scale),
H: ToDp(r.H, scale),
}
}
// FromDp converts a Region (Dp) to a RegionPx using the scale factor.
func FromDp(r Region, scale float32) RegionPx {
return RegionPx{
X: ToPx(r.X, scale),
Y: ToPx(r.Y, scale),
W: ToPx(r.W, scale),
H: ToPx(r.H, scale),
}
}
// GlyphLayout holds per-glyph layout data captured during text shaping.
// Each index i represents one glyph (one rune in the source string).
// ByteOffsets[i] is the byte position in the buffer, (X[i], Y[i]) is the
// glyph's screen location in Dp (X relative to text region origin, Y is the
// shaper baseline), and Advance[i] is the glyph's width in Dp.
// LineHeight is the shaper's actual baseline-to-baseline line height in Dp,
// derived from consecutive lines' Y values.
// LayoutFeedback carries the renderer's per-frame glyph layout back to the
// logic goroutine, together with the window the layout was shaped for.
// A scroll may move the window between shaping and delivery, so the
// correlation pass applies the layout's wrap counts to the lines THIS layout
// describes: WindowText is the exact text that was shaped (grouping the
// VisualLineStarts over the current window instead would attribute counts to
// the wrong lines whenever the window moved), and WindowStartLine is the
// logical line that text begins at. EditSeq correlates with the editor's
// content-edit counter: a feedback whose EditSeq differs from the current
// state was shaped before an edit and its counts must be dropped (an edit
// shifts lines).
type LayoutFeedback struct {
GlyphLayout GlyphLayout
WindowText string // the exact text this layout was shaped for
WindowStartByte int // absolute byte offset of the window's first byte
WindowStartLine int // logical line the window starts at (-1: none)
EditSeq uint64 // editor content-edit counter at frame time
}
type GlyphLayout struct {
ByteOffsets []int // byte offset of each glyph in the buffer
X []Dp // screen X (Dp) of each glyph, relative to text region origin
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
Advance []Dp // advance width (Dp) of each glyph
LineHeight Dp // shaper's actual baseline-to-baseline line height in Dp
VisualLineStarts []int // byte offsets where each visual line starts (for word wrap)
VisualLineIndex *types.VisualLineIndex // Optional: pre-computed visual line index for this layout
}
// VisualLineOffsets returns the byte offset of each visual line start.
// Uses pre-captured VisualLineStarts if available, otherwise computes from glyph data.
func (gl GlyphLayout) VisualLineOffsets() []int32 {
if len(gl.VisualLineStarts) > 0 {
// Use pre-captured visual line starts (more accurate for word wrap)
offsets := make([]int32, len(gl.VisualLineStarts))
for i, v := range gl.VisualLineStarts {
offsets[i] = int32(v)
}
return offsets
}
// Fallback: compute from glyph data (less accurate for word wrap)
var offsets []int32
if len(gl.ByteOffsets) == 0 {
return offsets
}
// The first line always starts at byte 0
offsets = append(offsets, int32(gl.ByteOffsets[0]))
// Add byte offset for each new Y coordinate
for i := 1; i < len(gl.Y); i++ {
if gl.Y[i] != gl.Y[i-1] {
offsets = append(offsets, int32(gl.ByteOffsets[i]))
}
}
return offsets
}