Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
154 lines
5.4 KiB
Go
154 lines
5.4 KiB
Go
package ui
|
|
|
|
import "testing"
|
|
|
|
// TestCaretPoint_EmptyFirstLine is a regression test for the caret jumping
|
|
// to an adjacent line while scrolling: when the shaped window's first visual
|
|
// line is empty (no recorded glyphs), the old code anchored the case-B
|
|
// baseline on the smallest recorded Y — the first NON-empty line — so every
|
|
// boundary caret (line-end "\n", an empty line's lone byte, EOF) drew one
|
|
// line too low per leading empty line, and the caret visibly jumped a line
|
|
// when the window scrolled past the empty line.
|
|
func TestCaretPoint_EmptyFirstLine(t *testing.T) {
|
|
const (
|
|
lh = Dp(16.8)
|
|
ascent = Dp(13.3)
|
|
)
|
|
// Window text starting on an empty line: line 0 is a lone "\n" (byte 0,
|
|
// no glyphs), line 1 is "abc" (bytes 1-3, "\n" at 4), line 2 is "def"
|
|
// (bytes 5-7, no trailing newline). Baselines are the uniform shaper
|
|
// grid: line i at ascent + i*lh; line 0 has no recorded glyphs.
|
|
str := "\nabc\ndef"
|
|
layout := GlyphLayout{
|
|
LineHeight: lh,
|
|
// VisualLineStarts: one entry per line start (0, 1, 5). The last
|
|
// line has no trailing break; the entry for it is byte 5.
|
|
VisualLineStarts: []int{0, 1, 5},
|
|
ByteOffsets: []int{1, 2, 3, 5, 6, 7},
|
|
X: []Dp{0, 10, 20, 0, 10, 20},
|
|
Y: []Dp{ascent + lh, ascent + lh, ascent + lh, ascent + 2*lh, ascent + 2*lh, ascent + 2*lh},
|
|
Advance: []Dp{10, 10, 10, 10, 10, 10},
|
|
}
|
|
|
|
near := func(got, want Dp) bool {
|
|
d := float64(got - want)
|
|
return d < 0.01 && d > -0.01
|
|
}
|
|
check := func(name string, byteOff int, wantX, wantY Dp) {
|
|
t.Helper()
|
|
x, y := CaretPoint(layout, str, byteOff, ascent, lh)
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("%s: CaretPoint(%d) = (%v, %v); want (%v, %v)", name, byteOff, x, y, wantX, wantY)
|
|
}
|
|
}
|
|
|
|
// Case A: a glyph-start byte is unaffected (always was) — "a" of line 1.
|
|
check("glyph start (line 1)", 1, 0, ascent+lh)
|
|
|
|
// Case B: the empty first line's lone insertion point sits on line 0.
|
|
// Old code: minY = ascent+lh, line 0 -> y = ascent+lh (one line low).
|
|
check("empty line 0 origin", 0, 0, ascent)
|
|
|
|
// Case B: the terminating "\n" of line 1 sits at line 1's right edge.
|
|
// Old code: minY = ascent+lh, line 1 -> y = ascent+2*lh (one line low).
|
|
check("line 1 end (\\n)", 4, 30, ascent+lh)
|
|
|
|
// Case B: EOF on the last line sits at line 2's right edge.
|
|
// Old code: y = ascent+lh+2*lh (one line low).
|
|
check("EOF (line 2 end)", 8, 30, ascent+2*lh)
|
|
}
|
|
|
|
// TestCaretPoint_EmptyFirstLines covers several leading empty lines: the
|
|
// anchor must walk back j full line heights, not one.
|
|
func TestCaretPoint_EmptyFirstLines(t *testing.T) {
|
|
const (
|
|
lh = Dp(16.8)
|
|
ascent = Dp(13.3)
|
|
)
|
|
// Two empty lines, then "abc".
|
|
str := "\n\nabc"
|
|
layout := GlyphLayout{
|
|
LineHeight: lh,
|
|
VisualLineStarts: []int{0, 1, 2},
|
|
ByteOffsets: []int{2, 3, 4},
|
|
X: []Dp{0, 10, 20},
|
|
Y: []Dp{ascent + 2*lh, ascent + 2*lh, ascent + 2*lh},
|
|
Advance: []Dp{10, 10, 10},
|
|
}
|
|
near := func(got, want Dp) bool {
|
|
d := float64(got - want)
|
|
return d < 0.01 && d > -0.01
|
|
}
|
|
// End of the "abc" line (no trailing newline): right edge of line 2.
|
|
x, y := CaretPoint(layout, str, 5, ascent, lh)
|
|
wantX, wantY := Dp(30), ascent+2*lh
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("EOF after two empty lines = (%v, %v); want (%v, %v)", x, y, wantX, wantY)
|
|
}
|
|
// The second empty line's origin: line 1.
|
|
x, y = CaretPoint(layout, str, 1, ascent, lh)
|
|
wantX, wantY = Dp(0), ascent+lh
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("empty line 1 origin = (%v, %v); want (%v, %v)", x, y, wantX, wantY)
|
|
}
|
|
}
|
|
|
|
// TestCaretPoint_NonEmptyFirstLine guards against regressions in the common
|
|
// case: with a glyph on the window's first line the anchor is the first
|
|
// recorded baseline (the old behaviour), unchanged.
|
|
func TestCaretPoint_NonEmptyFirstLine(t *testing.T) {
|
|
const (
|
|
lh = Dp(16.8)
|
|
ascent = Dp(13.3)
|
|
)
|
|
str := "abc\ndef"
|
|
layout := GlyphLayout{
|
|
LineHeight: lh,
|
|
VisualLineStarts: []int{0, 4},
|
|
ByteOffsets: []int{0, 1, 2, 4, 5, 6},
|
|
X: []Dp{0, 10, 20, 0, 10, 20},
|
|
Y: []Dp{ascent, ascent, ascent, ascent + lh, ascent + lh, ascent + lh},
|
|
Advance: []Dp{10, 10, 10, 10, 10, 10},
|
|
}
|
|
near := func(got, want Dp) bool {
|
|
d := float64(got - want)
|
|
return d < 0.01 && d > -0.01
|
|
}
|
|
// Line 0's "\n": line 0's right edge at line 0's baseline (ascent).
|
|
x, y := CaretPoint(layout, str, 3, ascent, lh)
|
|
wantX, wantY := Dp(30), ascent
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("line 0 end = (%v, %v); want (%v, %v)", x, y, wantX, wantY)
|
|
}
|
|
// EOF: line 1's right edge.
|
|
x, y = CaretPoint(layout, str, 7, ascent, lh)
|
|
wantX, wantY = Dp(30), ascent+lh
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("EOF = (%v, %v); want (%v, %v)", x, y, wantX, wantY)
|
|
}
|
|
}
|
|
|
|
// TestCaretPoint_NoGlyphs covers a window of pure newlines: the ascent
|
|
// fallback anchor applies.
|
|
func TestCaretPoint_NoGlyphs(t *testing.T) {
|
|
const (
|
|
lh = Dp(16.8)
|
|
ascent = Dp(13.3)
|
|
)
|
|
str := "\n\n\n"
|
|
layout := GlyphLayout{
|
|
LineHeight: lh,
|
|
VisualLineStarts: []int{0, 1, 2},
|
|
}
|
|
near := func(got, want Dp) bool {
|
|
d := float64(got - want)
|
|
return d < 0.01 && d > -0.01
|
|
}
|
|
// The second empty line's origin: line 1.
|
|
x, y := CaretPoint(layout, str, 1, ascent, lh)
|
|
wantX, wantY := Dp(0), ascent+lh
|
|
if !near(x, wantX) || !near(y, wantY) {
|
|
t.Errorf("pure-newline window, empty line 1 = (%v, %v); want (%v, %v)", x, y, wantX, wantY)
|
|
}
|
|
}
|