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.
206 lines
6.8 KiB
Go
206 lines
6.8 KiB
Go
package editor
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"pad/internal/io/pool/types"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// TestApplyWrapCounts_Grouping verifies that a shaped window's
|
|
// VisualLineStarts (window-relative byte offsets, one per visual line,
|
|
// including the initial 0) are attributed to the correct logical lines:
|
|
// a wrapped logical line gets its true visual-line count, and lines the
|
|
// layout does not cover keep their previous counts (over-stale is safe,
|
|
// under-attributing is not).
|
|
func TestApplyWrapCounts_Grouping(t *testing.T) {
|
|
// Whole-file buffer; the shaped window covers lines 5..8.
|
|
var lines []string
|
|
for i := 0; i < 12; i++ {
|
|
lines = append(lines, "line-content-"+strings.Repeat("x", i%7))
|
|
}
|
|
// The windowed lines get distinctive shapes.
|
|
lines[5] = "A very long line that wraps three times"
|
|
lines[6] = "short"
|
|
lines[7] = "" // empty line
|
|
lines[8] = "another line that wraps twice"
|
|
content := strings.Join(lines, "\n") + "\n"
|
|
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
|
|
// Distinct seed counts so any (un)set line is distinguishable.
|
|
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
|
cb.WrapIndex.Set(i, int32(9))
|
|
}
|
|
|
|
// The shaped window covers lines 5..8 (their content plus terminators).
|
|
winStart := byteOffsetOfLine(content, 5)
|
|
windowText := strings.Join(lines[5:9], "\n") + "\n"
|
|
off := map[string]int{}
|
|
crs := 0
|
|
for _, l := range lines[5:9] {
|
|
off[l] = crs
|
|
crs += len(l) + 1
|
|
}
|
|
// Fabricated visual line starts (window-relative), as the shaper would
|
|
// emit them: 0 first, then one per wrapped continuation, then one per
|
|
// following logical line start.
|
|
starts := []int{
|
|
0, // line 5 visual 1
|
|
20, // line 5 wraps
|
|
35, // line 5 wraps again
|
|
off["short"], // line 6
|
|
off[""], // line 7 (empty)
|
|
off["another line that wraps twice"], // line 8
|
|
off["another line that wraps twice"] + 19, // line 8 wraps
|
|
}
|
|
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.IMEWindowText = windowText
|
|
TheState.Editor.IMEWindowStartByte = winStart
|
|
TheState.Editor.EditSeq = 42
|
|
|
|
fb := ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts, LineHeight: 20},
|
|
WindowText: windowText,
|
|
WindowStartByte: winStart,
|
|
WindowStartLine: 5,
|
|
EditSeq: 42,
|
|
}
|
|
TheState.applyWrapCounts(fb)
|
|
|
|
want := map[int]int32{5: 3, 6: 1, 7: 1, 8: 2}
|
|
for line, wc := range want {
|
|
if got := cb.WrapIndex.Get(line); got != wc {
|
|
t.Errorf("line %d count = %d, want %d", line, got, wc)
|
|
}
|
|
}
|
|
// Untouched lines keep their seed count.
|
|
for _, line := range []int{0, 1, 2, 3, 4, 9, 10, 11} {
|
|
if got := cb.WrapIndex.Get(line); got != 9 {
|
|
t.Errorf("line %d count = %d, want untouched 9", line, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestApplyWrapCounts_TrailingNewline covers a window whose last line ends
|
|
// with '\n' at the window end: the shaper may emit a visual-line start at
|
|
// exactly len(winText) for the (empty) trailing line; it must not be
|
|
// attributed to the last real line.
|
|
func TestApplyWrapCounts_TrailingNewline(t *testing.T) {
|
|
content := "short\nlong line that wraps once here\nlast line ends with newline\n"
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
|
cb.WrapIndex.Set(i, 7)
|
|
}
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.IMEWindowText = content
|
|
TheState.Editor.IMEWindowStartByte = 0
|
|
TheState.Editor.EditSeq = 1
|
|
|
|
// Line 0: "short" (1); line 1: wraps (2); line 2: "last line..." (1);
|
|
// plus a trailing-empty-line start at len(content).
|
|
starts := []int{0, 6, 6 + 18, len("short\nlong line that wraps once here\n"), len(content)}
|
|
fb := ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts},
|
|
WindowText: content,
|
|
WindowStartByte: 0,
|
|
WindowStartLine: 0,
|
|
EditSeq: 1,
|
|
}
|
|
TheState.applyWrapCounts(fb)
|
|
|
|
if got := cb.WrapIndex.Get(0); got != 1 {
|
|
t.Errorf("line 0 = %d, want 1", got)
|
|
}
|
|
if got := cb.WrapIndex.Get(1); got != 2 {
|
|
t.Errorf("line 1 = %d, want 2", got)
|
|
}
|
|
// Line 2 keeps the seed: the entry at len(content) belongs to the empty
|
|
// trailing line, which is already at the estimate.
|
|
if got := cb.WrapIndex.Get(2); got != 1 && got != 7 {
|
|
t.Errorf("line 2 = %d, want 1 (or untouched 7)", got)
|
|
}
|
|
}
|
|
|
|
// TestApplyWrapCounts_Guards pins the early-outs: no buffer, no wrap index,
|
|
// negative window start, empty window text, empty starts, and a window start
|
|
// byte past the window text.
|
|
func TestApplyWrapCounts_Guards(t *testing.T) {
|
|
content := "abc\ndef\n"
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
cb.WrapIndex.Set(0, 5)
|
|
cb.WrapIndex.Set(1, 5)
|
|
cb.WrapIndex.Set(2, 5)
|
|
|
|
base := func() *ui.LayoutFeedback {
|
|
return &ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: []int{0, 4}},
|
|
WindowText: content,
|
|
WindowStartByte: 0,
|
|
WindowStartLine: 0,
|
|
EditSeq: 0,
|
|
}
|
|
}
|
|
run := func(mut func(*State, *ui.LayoutFeedback)) {
|
|
TheState = NewState()
|
|
cbc := newTestBufferForWrapWholeFile(content)
|
|
cbc.WrapIndex.Set(0, 5)
|
|
cbc.WrapIndex.Set(1, 5)
|
|
cbc.WrapIndex.Set(2, 5)
|
|
TheState.Editor.ChunkedBuffer = cbc
|
|
TheState.Editor.IMEWindowText = content
|
|
TheState.Editor.IMEWindowStartByte = 0
|
|
fb := base()
|
|
wi := cbc.WrapIndex // capture before mut (some cases nil it)
|
|
mut(TheState, fb)
|
|
TheState.applyWrapCounts(*fb)
|
|
for i := 0; i < 3; i++ {
|
|
if got := wi.Get(i); got != 5 {
|
|
t.Errorf("guard case: line %d = %d, want untouched 5", i, got)
|
|
}
|
|
}
|
|
}
|
|
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer = nil })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer.WrapIndex = nil })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowStartLine = -1 })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowText = "" })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.GlyphLayout.VisualLineStarts = nil })
|
|
_ = cb
|
|
}
|
|
|
|
// newTestBufferForWrapWholeFile builds a chunked buffer + LineIndex +
|
|
// all-ones WrapIndex over the given content (the LineIndex convention: a
|
|
// trailing "\n" opens an empty trailing line).
|
|
func newTestBufferForWrapWholeFile(content string) *ChunkedBuffer {
|
|
cb := NewChunkedBuffer("/wrap-group.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent([]byte(content))
|
|
offsets := []int32{0}
|
|
for i := 0; i < len(content); i++ {
|
|
if content[i] == '\n' {
|
|
offsets = append(offsets, int32(i+1))
|
|
}
|
|
}
|
|
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content)))
|
|
cb.WrapIndex = NewWrapIndex(len(offsets))
|
|
return cb
|
|
}
|
|
|
|
// byteOffsetOfLine returns the byte offset of the start of 0-based line i
|
|
// under the LineIndex convention.
|
|
func byteOffsetOfLine(content string, i int) int {
|
|
o := 0
|
|
for line := 0; line < i; line++ {
|
|
nx := strings.IndexByte(content[o:], '\n')
|
|
if nx < 0 {
|
|
return len(content)
|
|
}
|
|
o += nx + 1
|
|
}
|
|
return o
|
|
}
|
|
|