diff --git a/cmd/pad/main.go b/cmd/pad/main.go index bb60f99..14d365c 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -284,6 +284,10 @@ func run(w *app.Window) error { }) case key.SnippetEvent: // Handle snippet event if necessary, or ignore + case key.FocusEvent: + // Focus gain/loss on the key queue (emitted when key.FocusCmd is + // issued, i.e. on focus transitions). Nothing to do: the logic + // layer already owns focus state. default: log.Printf("unexpected event type: %T", k) } diff --git a/internal/browser/layout.go b/internal/browser/layout.go index 1cf0bc0..9634322 100644 --- a/internal/browser/layout.go +++ b/internal/browser/layout.go @@ -127,15 +127,13 @@ func recomputeSearchResults(s *BrowserState) { if s.SortIndex != nil && s.SortIndex.SortOrders != nil { // Use SortIndex for searching (production path) positionMap := s.getSortedIndices() - if positionMap != nil { - for sortedIdx, rawIdx := range positionMap { - if rawIdx < 0 || rawIdx >= len(s.SortIndex.Entries) { - continue - } - entry := s.SortIndex.Entries[rawIdx] - if strings.Contains(strings.ToLower(entry.Name), queryLower) { - results = append(results, sortedIdx) - } + for sortedIdx, rawIdx := range positionMap { + if rawIdx < 0 || rawIdx >= len(s.SortIndex.Entries) { + continue + } + entry := s.SortIndex.Entries[rawIdx] + if strings.Contains(strings.ToLower(entry.Name), queryLower) { + results = append(results, sortedIdx) } } } else { diff --git a/internal/browser/types.go b/internal/browser/types.go index 86032f8..bc3ede4 100644 --- a/internal/browser/types.go +++ b/internal/browser/types.go @@ -34,9 +34,6 @@ type BrowserState struct { EntryHeight float64 // Height of a single entry in pixels VisibleCount int // Number of entries currently visible - // Computed from ScrollOffset and EntryHeight - scrollIndex int // Index of first visible entry (derived, not persisted) - // Lazy loading Pages map[int]*Page // Loaded pages by page index TotalEntries int // Total entry count (from cached index) diff --git a/internal/editor/e2e_test.go b/internal/editor/e2e_test.go index a4cd483..b1fb5fe 100644 --- a/internal/editor/e2e_test.go +++ b/internal/editor/e2e_test.go @@ -107,8 +107,7 @@ func TestAutoSaveE2E(t *testing.T) { // After saving, the file is re-opened and the full content is compared // byte-for-byte against the expected result. func TestLargeFileChunkBoundary(t *testing.T) { - const chunkSize = DefaultChunkSize // 64 KB - const fileSize = 256 * 1024 // 4 chunks + const fileSize = 256 * 1024 // 4 chunks // --- 1. Setup: create a 256 KB file with a predictable pattern --- mockFS := mock.NewFileSystem() diff --git a/internal/editor/logic.go b/internal/editor/logic.go index e8576fa..3cd25e4 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -583,20 +583,6 @@ func (l *Logic) handleWorkerResult(res pool.Result) { l.emitFrame() } -// applyBuildIndexResult applies a completed BuildIndexTask result to browser state. -func (l *Logic) applyBuildIndexResult(res pool.Result) { - // Delegated to browserManager -} - -// applyLoadPagesResult applies a completed LoadPagesTask result to browser state. -func (l *Logic) applyLoadPagesResult(res pool.Result) { - // Delegated to browserManager -} - -// applyReadDirResult applies a completed ReadDirTask result to browser state. -func (l *Logic) applyReadDirResult(res pool.Result) { - // Delegated to browserManager -} // State returns the current state. func (l *Logic) State() *State { diff --git a/internal/editor/mock_setup.go b/internal/editor/mock_setup.go index 9d78a9f..9c73c62 100644 --- a/internal/editor/mock_setup.go +++ b/internal/editor/mock_setup.go @@ -1,14 +1,10 @@ package editor import ( - "sort" - "strings" "time" - "pad/internal/browser" "pad/internal/io/pool" "pad/internal/io/pool/mock" - "pad/internal/io/pool/types" ) // populateMockFileSystem seeds the mock filesystem with sample data for testing. @@ -139,114 +135,3 @@ func populateMockFileSystem(fs pool.FileSystem) { mfs.AddFile("/Downloads/"+f.name, []byte(f.content), baseTime.AddDate(0, 0, f.days)) } } - -// sortModeKey converts a browser.SortMode to its JSON key string. -func sortModeKey(mode browser.SortMode) string { - switch mode { - case 0: // SortModeNameAsc - return "name_asc" - case 1: // SortModeNameDesc - return "name_desc" - case 2: // SortModeDateAsc - return "date_asc" - case 3: // SortModeDateDesc - return "date_desc" - default: - return "" - } -} - -// modeCount returns the total number of sort modes. -func modeCount() int { - return 4 -} - -// buildBrowserIndex converts types.DirEntry results into a browser.DirectoryIndex -// with pre-computed position maps for all sort modes. -func buildBrowserIndex(entries []types.DirEntry) *browser.DirectoryIndex { - var browserEntries []browser.Entry - for _, e := range entries { - info, err := e.Info() - if err != nil { - continue - } - browserEntries = append(browserEntries, browser.Entry{ - Path: e.Name(), - Name: e.Name(), - Size: info.Size(), - ModTime: info.ModTime(), - IsDir: info.IsDir(), - }) - } - - // Build position maps for all sort modes - sortOrders := make(map[string][]int) - for mode := browser.SortMode(0); mode < browser.SortMode(modeCount()); mode++ { - key := sortModeKey(mode) - if key != "" { - sortOrders[key] = buildPositionMap(browserEntries, mode) - } - } - - return &browser.DirectoryIndex{ - Path: "/", - EntryCount: len(browserEntries), - Entries: browserEntries, - SortOrders: sortOrders, - } -} - -// buildPositionMap creates a sorted index → raw index mapping. -func buildPositionMap(entries []browser.Entry, mode browser.SortMode) []int { - n := len(entries) - indices := make([]int, n) - for i := range indices { - indices[i] = i - } - - // Sort indices based on entry comparison - cmp := comparator(mode) - sort.SliceStable(indices, func(i, j int) bool { - a, b := entries[indices[i]], entries[indices[j]] - return cmp(a, b) < 0 - }) - - return indices -} - -// comparator returns a comparison function for the given SortMode. -// Returns negative if a < b, zero if equal, positive if a > b. -func comparator(mode browser.SortMode) func(a, b browser.Entry) int { - switch mode { - case 0: // SortModeNameAsc - return func(a, b browser.Entry) int { - if c := strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); c != 0 { - return c - } - return strings.Compare(a.Name, b.Name) - } - case 1: // SortModeNameDesc - return func(a, b browser.Entry) int { - if c := strings.Compare(strings.ToLower(b.Name), strings.ToLower(a.Name)); c != 0 { - return c - } - return strings.Compare(b.Name, a.Name) - } - case 2: // SortModeDateAsc - return func(a, b browser.Entry) int { - if c := a.ModTime.Compare(b.ModTime); c != 0 { - return c - } - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - } - case 3: // SortModeDateDesc - return func(a, b browser.Entry) int { - if c := b.ModTime.Compare(a.ModTime); c != 0 { - return c - } - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - } - default: - return func(a, b browser.Entry) int { return 0 } - } -} diff --git a/internal/editor/state.go b/internal/editor/state.go index c382f81..1e1473f 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -3,7 +3,6 @@ package editor import ( "fmt" "log" - "math" "sort" "strings" "time" @@ -107,16 +106,26 @@ type EditorState struct { SelDragging bool SelDragWhich int SelDragRel int - // SelDragPressY is the text-local Y of the first event of an in-progress - // handle/body drag (the grab), and SelDragPressLine the visual line of - // the dragged anchor AT THE GRAB. Start/end handles move relative to - // these (see handleAnchorPos). The base line must be captured once at the - // grab: re-resolving it from the anchor's current byte on every event - // makes the anchor's own movement feed back into its target, and a - // jittering finger near a line boundary races the anchor off the screen. - SelDragPressY float64 - SelDragPressLine int - Filename string + // SelDragSwapped: during a start/end-handle drag the finger crossed the + // opposite handle and the selection was flipped (native behaviour): the + // dragged handle now controls the end it crossed. Cleared when the finger + // crosses back or the drag ends. + SelDragSwapped bool + // SelDragPressX/Y is the text-local position of the first event of an + // in-progress handle/caret drag (the grab), and SelDragAnchorX/Y the + // text-local point of the dragged anchor AT THE GRAB (see + // textPointLocal). Each later event moves the anchor by the finger's + // displacement from the grab, so the handle tracks the finger 1:1 (the + // teardrop stays under the finger) and the selection resizes + // continuously — no line snapping, no jump on grab. The reference is + // fixed at the grab, so the anchor's own movement cannot feed back into + // its target: a jittering finger near a line boundary cannot race the + // anchor off the screen. + SelDragPressX float64 + SelDragPressY float64 + SelDragAnchorX float64 + SelDragAnchorY float64 + Filename string // TooLarge is set when an opened file exceeds MaxEditableFileSize. The // editor shows a "too large to edit" notice instead of content (the // browser can still list the file). @@ -1096,6 +1105,7 @@ func HandleSelDragEvt(data any) { selDragMove(ev.Which, ev.X, ev.Y) case ui.SelectionDragEnd: TheState.Editor.SelDragging = false + TheState.Editor.SelDragSwapped = false TheState.Editor.SelDragRel = 0 // The caret handle (long press on blank space) is a transient affordance; // the selection handles come back from the selection state on the next @@ -1113,17 +1123,9 @@ func selDragMove(which int, x, y ui.Dp) { if !e.SelDragging { e.SelDragging = true e.SelDragWhich = which + e.SelDragSwapped = false + e.SelDragPressX = localX e.SelDragPressY = localY - if which == 0 { - if l, ok2 := visualLineOfByte(e.SelectionStart - glyphBase()); ok2 { - e.SelDragPressLine = l - } - } - if which == 1 { - if l, ok2 := visualLineOfByte(e.SelectionEnd - glyphBase()); ok2 { - e.SelDragPressLine = l - } - } if which == 2 && ok { // Body drag: the grab fixes the finger's offset from the selection // start; the selection itself moves on later events. @@ -1133,30 +1135,73 @@ func selDragMove(which int, x, y ui.Dp) { } return } - // Start/end/caret handles: the first event already positions the - // handle (no grab offset needed). - } - if !ok { - return // finger outside the laid-out window: keep last position + // Start/end/caret handles: the grab fixes the anchor's text position; + // later events move it by the finger's displacement from the grab. + var anchor int + switch which { + case 0: + anchor = e.SelectionStart + case 1: + anchor = e.SelectionEnd + default: // 3: caret + anchor = e.CursorPosition + } + if ax, ay, ok2 := textPointLocal(anchor); ok2 { + e.SelDragAnchorX = ax + e.SelDragAnchorY = ay + } + return } switch e.SelDragWhich { - case 0: // start handle - if p2, ok2 := handleAnchorPos(e, e.SelectionStart, localX, localY); ok2 { - pos = p2 + case 0, 1: // start / end handle + // Move the anchor by the finger's displacement from the grab (1:1 + // tracking: the teardrop stays under the finger, the selection + // resizes continuously, lines are crossed only when the finger's + // mapped point crosses them). + pos, ok = handleFollowPos(e, localX, localY) + if !ok { + return // finger outside the laid-out window: keep last position } - if pos > e.SelectionEnd { - pos = e.SelectionEnd + // Crossing the opposite handle flips the selection (native behaviour) + // instead of clamping to zero length and clearing it; while flipped the + // dragged handle controls the end it crossed. + if e.SelDragWhich == 0 { + if !e.SelDragSwapped { + if pos > e.SelectionEnd { + e.SelDragSwapped = true + SetSelection(e.SelectionEnd, pos) + } else if pos < e.SelectionEnd { + SetSelection(pos, e.SelectionEnd) + } + } else { + if pos < e.SelectionStart { + e.SelDragSwapped = false + SetSelection(pos, e.SelectionStart) + } else if pos > e.SelectionStart { + SetSelection(e.SelectionStart, pos) + } + } + } else { + if !e.SelDragSwapped { + if pos < e.SelectionStart { + e.SelDragSwapped = true + SetSelection(pos, e.SelectionStart) + } else if pos > e.SelectionStart { + SetSelection(e.SelectionStart, pos) + } + } else { + if pos > e.SelectionEnd { + e.SelDragSwapped = false + SetSelection(e.SelectionEnd, pos) + } else if pos < e.SelectionEnd { + SetSelection(e.SelectionEnd, pos) + } + } } - SetSelection(pos, e.SelectionEnd) - case 1: // end handle - if p2, ok2 := handleAnchorPos(e, e.SelectionEnd, localX, localY); ok2 { - pos = p2 - } - if pos < e.SelectionStart { - pos = e.SelectionStart - } - SetSelection(e.SelectionStart, pos) case 2: // body: move the whole selection, preserving length + if !ok { + return + } selLen := e.SelectionEnd - e.SelectionStart ns := pos - e.SelDragRel fl := fileLenBytes() @@ -1168,51 +1213,87 @@ func selDragMove(which int, x, y ui.Dp) { } SetSelection(ns, ns+selLen) case 3: // caret drag handle - e.CursorPosition = pos + if p, ok2 := handleFollowPos(e, localX, localY); ok2 { + e.CursorPosition = p + } } } -// handleAnchorPos resolves the new anchor byte for a start/end-handle drag -// event. The anchor moves RELATIVE to its own visual line: the finger's -// vertical displacement from the grab (in whole visual lines) selects the -// target line, and the finger's x is projected onto that line. -// -// This gives the native behaviour — dragging a handle down extends the -// selection across lines, dragging it up shrinks from the far side — while -// keeping the grab safe. The 48dp grab box is centred BELOW the line (the -// teardrop hangs off the line's bottom edge), so the press usually lands on -// the neighbouring line; mapping the press to its own line used to clamp the -// anchor onto the other handle and clear the selection on the very first -// drag event. With the relative mapping a stationary or horizontal drag -// (displacement under half a line) never moves the anchor off its line, and -// a deliberate vertical drag crosses lines only after the finger has moved -// past half a line height from the grab. -func handleAnchorPos(e *EditorState, anchorByte int, localX, localY float64) (int, bool) { - // Base line is the anchor's line at the GRAB (captured in selDragMove), - // never the anchor's current line: the anchor's own movement must not - // feed back into its target. - line := e.SelDragPressLine - lh := float64(EffectiveLineHeight()) - if lh > 0 { - target := line + int(math.Round((localY-e.SelDragPressY)/lh)) - if target < 0 { - target = 0 - } - return textPosOnLineAtX(target, localX) +// handleFollowPos maps the dragged handle's target point — the anchor's grab +// position plus the finger's displacement from the grab — to a byte offset. +func handleFollowPos(e *EditorState, localX, localY float64) (int, bool) { + tx := e.SelDragAnchorX + (localX - e.SelDragPressX) + ty := e.SelDragAnchorY + (localY - e.SelDragPressY) + return textPosFromLocalPoint(tx, ty) +} + +// textPointLocal is the inverse of textPosFromLocalPoint: the text-local +// (x, y) point that maps back to byteOff. x is the insertion point's +// x (the glyph's left edge, or the previous glyph's right edge), and y is +// the MIDDLE of the byte's visual line band so that textPosFromLocalPoint's +// int(y/lineHeight) line resolution lands on the byte's own line. +func textPointLocal(byteOff int) (x, y float64, ok bool) { + layout := TheState.Editor.GlyphLayout + if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 { + return 0, 0, false } - return textPosOnLineAtX(line, localX) + lh := float64(EffectiveLineHeight()) + if lh <= 0 { + return 0, 0, false + } + base := glyphBase() + winByte := byteOff - base + if winByte < 0 { + winByte = 0 + } + idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { + return layout.ByteOffsets[i] >= winByte + }) + if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] == winByte { + x = float64(layout.X[idx]) + } else if idx > 0 { + // The byte sits between glyphs (mid-multibyte) or past the last + // glyph (EOF): the previous glyph's right edge. + x = float64(layout.X[idx-1] + layout.Advance[idx-1]) + } + line, ok2 := visualLineOfByte(winByte) + if !ok2 { + return 0, 0, false + } + y = (float64(line) + 0.5) * lh + return x, y, true } // visualLineOfByte returns the visual line (0-based within the shaped // window) that holds the insertion point at the given window-relative byte -// offset, using the same rule the renderer uses to place the handles -// (handleAt): the line of the first glyph whose byte offset is at or past -// the offset, or the last line when the offset is past the last glyph. +// offset: the last visual line whose start byte is at or before the offset. +// A line's terminating newline belongs to that line, and an empty line's +// lone insertion point sits on the empty line (the first-glyph-at-or-past +// rule would put both on the next line, which is wrong for empty lines). func visualLineOfByte(winByte int) (int, bool) { layout := TheState.Editor.GlyphLayout if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 { return 0, false } + // The insertion point at winByte is drawn on the last visual line whose + // first byte is at or before it: a line's terminating newline belongs to + // that line, and an empty line's lone insertion point sits on the empty + // line. (Looking at the first glyph at/past the byte instead is wrong + // around empty lines: that glyph is on the NEXT line.) This must agree + // with textPosOnLineAtX's line starts so a dragged handle's grab anchor + // round-trips. + if starts := layout.VisualLineStarts; len(starts) > 0 { + if winByte < 0 { + winByte = 0 + } + idx := sort.Search(len(starts), func(i int) bool { + return starts[i] > winByte + }) + if idx > 0 { + idx-- + } + return idx, true + } lineHeight := float64(EffectiveLineHeight()) minY := 1e9 for _, yVal := range layout.Y { @@ -1946,11 +2027,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // window could leave the lower rows blank). visibleContent = cb.Content(start, end) - // Adjust cursor position to be relative to visibleContent + // Adjust cursor position to be relative to visibleContent. It may be + // NEGATIVE (the cursor's line scrolled above the window) or exceed + // len(visibleContent) (scrolled below): the renderer treats an + // out-of-window cursor as off-screen and draws no caret, like a native + // editor. Clamping to the window edge here made the caret appear on + // the top/bottom visible line whenever the user scrolled past it. visibleCursorPos = TheState.Editor.CursorPosition - start - if visibleCursorPos < 0 { - visibleCursorPos = 0 - } // Adjust scroll offset to be relative to visibleContent origin // Sub-line shift for the renderer: the SAME visual-line decomposition @@ -2284,7 +2367,7 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { y float64 indices []int } - groups := []lineGroup{} + var groups []lineGroup // assigned in the grouping loop below // Find all unique baseline Ys // The tap Y is based on line height (top of line). @@ -2303,11 +2386,20 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { } } - // Now group by baseline + // Now group by visual line. A glyph's visual line is resolved from the + // recorded line starts (VisualLineStarts), NOT from its Y-baseline: the + // window's first visual line may be an empty line with no recorded + // glyphs, in which case min(layout.Y) is the first NON-empty line's + // baseline and (Y-minY)/lineHeight numbers every line one too high — + // taps and dragged handles then landed one line below the finger + // whenever the viewport top sat on an empty line. groups = []lineGroup{} for i, yVal := range layout.Y { yFloat := float64(yVal) - lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line + lineIdx, ok := visualLineOfByte(layout.ByteOffsets[i]) + if !ok { + lineIdx = int((yFloat-minY)/lineHeight + 0.5) // round to nearest line + } if lineIdx < 0 { lineIdx = 0 } @@ -2327,7 +2419,17 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { visualLine = 0 } if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 { - // Clamp to last valid group that has indices + // A line with no glyphs: an empty line (a lone "\n"), or the trailing + // empty line of a file ending in "\n". Its single insertion point is + // the start of the line, before its terminating newline. Anchor there: + // a handle dragged across an empty line sweeps up to the line instead + // of snapping back to the last text line (or jumping past the empty + // line to the text after it). + if visualLine < len(layout.VisualLineStarts) { + return base + layout.VisualLineStarts[visualLine], true + } + // No line start recorded for this line (finger beyond the content): + // clamp to the last non-empty line. for i := len(groups) - 1; i >= 0; i-- { if len(groups[i].indices) > 0 { visualLine = i @@ -2335,7 +2437,7 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { } } } - if len(groups[visualLine].indices) == 0 { + if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 { return 0, false } @@ -2352,9 +2454,9 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { } } - // 4. Check if tap is to the right of the last character - if rightmostIdx != -1 && x > rightmostX { - // Position at the end of the line content, before any trailing newline. + // lineEndByte is the insertion point at the end of the line content, + // before any trailing newline. + lineEndByte := func() (int, bool) { start := base + layout.ByteOffsets[rightmostIdx] buf := TheState.Editor.ChunkedBuffer var fileContent string @@ -2371,31 +2473,41 @@ func textPosOnLineAtX(visualLine int, x float64) (int, bool) { r, size := utf8.DecodeRuneInString(fileContent[start:]) if r == '\n' { return start, true - } else { - return start + size, true } + return start + size, true } - // 5. Otherwise, find the closest glyph on this line. - bestIdx := -1 - minDist := float64(1e9) - for _, i := range targetGroup.indices { - if bestIdx == -1 { - bestIdx = i - } - // Calculate distance to the glyph center - glyphCenterX := float64(layout.X[i] + layout.Advance[i]/2) - dist := glyphCenterX - x - if dist < 0 { - dist = -dist - } - if dist < minDist { - minDist = dist - bestIdx = i - } + // 4. Check if tap is to the right of the last character + if rightmostIdx != -1 && x > rightmostX { + return lineEndByte() } - if bestIdx != -1 { - return base + layout.ByteOffsets[bestIdx], true + + // 5. Otherwise, map x to the nearest insertion point on the line: the + // glyph edge closest to x. x exactly on an edge maps to that edge's byte + // (this is where handle anchors sit, so a dragged handle must map back + // to its own byte), and a tie (x exactly midway between two edges) maps + // to the left byte. Tapping the right half of a glyph positions the + // caret after it, as on Android. + idxs := targetGroup.indices + // First glyph whose left edge is right of x. + m := sort.Search(len(idxs), func(k int) bool { + return float64(layout.X[idxs[k]]) > x + }) + if m == 0 { + // x at or left of the first glyph's left edge. + return base + layout.ByteOffsets[idxs[0]], true } - return 0, false + // x sits in the span of glyph idxs[m-1] (or, when m == len(idxs), at or + // past its left edge but within its span, since x <= rightmostX here). + g := idxs[m-1] + left := x - float64(layout.X[g]) + right := float64(layout.Advance[g]) - left + if left <= right { + return base + layout.ByteOffsets[g], true + } + if m < len(idxs) { + return base + layout.ByteOffsets[idxs[m]], true + } + // Right half of the last glyph: the insertion point after it. + return lineEndByte() } diff --git a/internal/editor/state_api_fuzz_test.go b/internal/editor/state_api_fuzz_test.go index 15c33d5..88b5c61 100644 --- a/internal/editor/state_api_fuzz_test.go +++ b/internal/editor/state_api_fuzz_test.go @@ -31,7 +31,7 @@ func fuzzStateAPI(t *testing.T, initial string, chunkSize, ops int) { cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(initial)), 0, int64(len(initial))) model := initial - cursor := len(model) + var cursor int // every op path sets this before the invariant reads it rng := rand.New(rand.NewSource(4242)) modelCap := 32 * 1024 diff --git a/internal/editor/touch_selection_test.go b/internal/editor/touch_selection_test.go index a486fb6..a506758 100644 --- a/internal/editor/touch_selection_test.go +++ b/internal/editor/touch_selection_test.go @@ -204,9 +204,12 @@ func TestHandleTapAt_InsideMenuIsIgnored(t *testing.T) { func TestSelDrag_EndHandleExtends(t *testing.T) { touchSelState("hello world") HandleLongPressAt(18, 108) // selects "hello" [0,5) - // Drag the end handle (which=1) past the last glyph (local 115 > 110) - // so the whole line is selected. - HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108}) + // Grab the end handle (which=1) near the anchor (byte 5 is at local + // x=50): the grab itself does not move the selection... + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 108}) + assertSelection(t, 0, 5) + // ...and dragging past the last glyph (x=50+63=113 > 110) selects the + // whole line: the anchor follows the finger 1:1. HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108}) assertSelection(t, 0, 11) } @@ -214,20 +217,49 @@ func TestSelDrag_EndHandleExtends(t *testing.T) { func TestSelDrag_StartHandleExtends(t *testing.T) { touchSelState("hello world") HandleLongPressAt(18, 108) // selects "hello" [0,5) - // Drag the start handle (which=0) to the 'o' (glyph 4, x=40..50; local 45 -> pt 61). - HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108}) + // Grab the start handle (which=0) near the anchor (byte 0 at local x=0). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, Y: 108}) + assertSelection(t, 0, 5) + // Drag right to x=0+43=43 (near glyph 4's centre 45): the start moves + // to byte 4. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108}) assertSelection(t, 4, 5) } -func TestSelDrag_StartHandleCollapseClears(t *testing.T) { +func TestSelDrag_StartHandleCrossesEndFlips(t *testing.T) { touchSelState("hello world") HandleLongPressAt(18, 108) // selects "hello" [0,5) - // Drag the start handle to the end of the selection -> zero-length -> cleared. - HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108}) - HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108}) - assertSelection(t, -1, -1) - assertMenu(t, false) + // Grab the start handle near the anchor (byte 0 at local x=0). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, Y: 108}) + // Drag past the end handle (x=0+64=64 -> glyph 'w' at byte 6): the + // selection flips (native behaviour) instead of clamping to zero length + // and clearing. The dragged handle now controls the end it crossed. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 66, Y: 108}) + assertSelection(t, 5, 6) + // While flipped, dragging before the (new) start flips back (x=0+14=14 + // -> byte 1). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 16, Y: 108}) + assertSelection(t, 1, 5) + HandleSelDragEvt(ui.SelectionDragEnd{}) +} + +func TestSelDrag_EndHandleCrossesStartFlips(t *testing.T) { + touchSelState("hello world") + HandleLongPressAt(16+62, 108) // selects "world" [6,11) + // Grab the end handle near the anchor (byte 11 at local x=110). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 112, Y: 108}) + // Drag before the start handle (x=110-96=14 -> glyph 'e' at byte 1): + // the selection flips (native behaviour) instead of clamping to zero + // length and clearing: it now spans the finger (byte 1) to the other + // handle (6). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 16, Y: 108}) + assertSelection(t, 1, 6) + // While flipped, the dragged handle controls the start: dragging back + // past the other handle (x=110-16=94 -> glyph 'l' at byte 9) un-flips, + // and the handle becomes the end again: [6,9). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 96, Y: 108}) + assertSelection(t, 6, 9) + HandleSelDragEvt(ui.SelectionDragEnd{}) } func TestSelDrag_BodyMovesPreservingLength(t *testing.T) { @@ -253,8 +285,13 @@ func TestSelDrag_CaretHandleMovesCursor(t *testing.T) { if !TheState.Editor.CaretDrag { t.Fatal("expected caret drag mode") } - // Drag the caret handle (which=3) to glyph 2 (local 25 -> pt 41). - HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108}) + // Grab the caret handle near the anchor (byte 12 at local x=120), then + // drag left so the mapped point is x=120-97=23 (glyph 2's centre 25): + // the caret follows the finger 1:1. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 122, Y: 108}) + if TheState.Editor.CursorPosition != 12 { + t.Errorf("cursor = %d after grab, want 12 (unchanged)", TheState.Editor.CursorPosition) + } HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108}) if TheState.Editor.CursorPosition != 2 { t.Errorf("cursor = %d after caret drag, want 2", TheState.Editor.CursorPosition) @@ -418,25 +455,29 @@ func TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection(t *testing.T) { touchSelState2Lines() HandleLongPressAt(18, 108) // selects "hello" [0,5) checkInitialSelection(t) - // Start handle (which=0), anchor at byte 0 (local x=0). Press at local + // Start handle (which=0), anchor at byte 0 (local x=0). Grab at local // (22, 25): localY 25 is on line 1 (line height 16.8), on the finger's // own line x=22 is byte 14 (past SelectionEnd=5) — the original bug's - // exact geometry. Zero vertical displacement from the grab -> the anchor - // stays on line 0, x=22 there is byte 2: the selection shrinks to "llo" - // but is never cleared. + // exact geometry. The anchor's reference is its own point (x=0, line 0) + // fixed at the grab, so the grab and a zero-displacement event do not + // move it — the selection is never cleared. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25}) HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25}) + assertSelection(t, 0, 5) + // Drag right by 20dp: the anchor moves to x=0+20=20 on its own line 0 — + // exactly the left edge of the glyph for byte 2, which maps to its own + // byte. The finger's own line is never used. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 42, Y: 100 + 25}) assertSelection(t, 2, 5) - // Continue dragging left: x=12 on line 0 is byte 1. - HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 12, Y: 100 + 25}) - assertSelection(t, 1, 5) // "ello" + // Continue: x=0+40=40 is the edge of byte 4. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 62, Y: 100 + 25}) + assertSelection(t, 4, 5) // "o" } // TestSelDrag_EndHandle_DragDownExtendsAcrossLines is the multi-line -// extension: grabbing the end handle and dragging it down more than half a -// line height moves the anchor to the next visual line (relative to the -// anchor's own line), so the selection grows across the newline — the -// native behaviour. +// extension: the anchor's mapped point (its grab position plus the finger's +// displacement) crosses into the next visual line, so the selection grows +// across the newline — the native behaviour. func TestSelDrag_EndHandle_DragDownExtendsAcrossLines(t *testing.T) { touchSelState2Lines() HandleLongPressAt(18, 108) // selects "hello" [0,5) @@ -445,10 +486,11 @@ func TestSelDrag_EndHandle_DragDownExtendsAcrossLines(t *testing.T) { // (52, 25) — the teardrop hangs below line 0, its box centre at y≈26.8. HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25}) assertSelection(t, 0, 5) // grab alone does not move the anchor - // Drag down 20dp (past half a line height of 16.8): the anchor crosses - // to line 1, x=52 there is byte 17 (the 'n' of "second"). + // Drag down 20dp: the mapped point (50, 8.4+20=28.4) is on line 1, where + // x=50 is the left edge of the glyph for byte 17 (exact edges map to + // their own byte). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 45}) - assertSelection(t, 0, 17) // "hello world\nsecond" minus the last d + assertSelection(t, 0, 17) // "hello world\nsecon" } // TestSelDrag_StartHandle_DragUpExtendsToLineAbove is the upward @@ -462,8 +504,8 @@ func TestSelDrag_StartHandle_DragUpExtendsToLineAbove(t *testing.T) { // local (0, 43) — the box centre for a line-1 handle is at y≈43.6. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 43}) checkSelection12_18(t) - // A small wobble (3dp) must NOT cross lines: still line 1, x=8 -> byte 12. - HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 8, Y: 100 + 46}) + // A small wobble (3dp) must NOT cross lines: still line 1, x=2 -> byte 12. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, Y: 100 + 46}) checkSelection12_18(t) // Drag up 20dp from the grab: the anchor crosses to line 0, x=0 there is // byte 0. @@ -513,12 +555,11 @@ func touchSelState3Lines() { } // TestSelDrag_EndHandle_JitterNearLineBoundary_Stable is a regression test -// for a feedback runaway: the target line must be computed from the -// anchor's line AT THE GRAB. Re-resolving the base line from the anchor's -// current byte on every event makes the anchor's own movement feed into its -// target: once the anchor crosses a line, every further event adds another -// line, and a jittering finger near a boundary races the anchor to the -// bottom of the file. +// for a feedback runaway: the anchor's reference point is fixed AT THE +// GRAB. Re-resolving it from the anchor's current byte on every event +// makes the anchor's own movement feed into its target: once the anchor +// crosses a line, every further event adds another line, and a jittering +// finger near a boundary races the anchor to the bottom of the file. func TestSelDrag_EndHandle_JitterNearLineBoundary_Stable(t *testing.T) { touchSelState3Lines() HandleLongPressAt(18, 108) // selects "hello" [0,5) @@ -526,10 +567,10 @@ func TestSelDrag_EndHandle_JitterNearLineBoundary_Stable(t *testing.T) { // End handle (which=1), anchor byte 5 on line 0. Grab at local (52, 25). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25}) checkInitialSelection(t) - // The finger hovers just past the half-line boundary (dY = 10, 11, 10 - // around the 8.4 threshold) and jitters. The anchor must settle one line - // down (byte 17, the 'n' of "second") and STAY there — not creep to - // line 2 (byte 29+) or further with each event. + // The finger hovers just past the line boundary (the mapped point's y + // = 18.4/19.4, past line 0's band of 0..16.8) and jitters. The anchor + // must settle one line down (byte 17) and STAY there — not creep to line + // 2 (byte 24+) or further with each event. for _, dy := range []int{35, 36, 35, 36, 35} { HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: ui.Dp(100 + dy)}) assertSelection(t, 0, 17) @@ -543,3 +584,137 @@ func checkInitialSelection(t *testing.T) { t.Fatalf("precondition: selection = [%d,%d), want [0,5)", s.SelectionStart, s.SelectionEnd) } } + +// touchSelStateLines is like touchSelState but builds a genuine multi-line +// layout from content: each non-newline byte becomes a 10dp-wide glyph at +// x=10*column on its visual line (baseline y = line*lineH), and +// VisualLineStarts records each visual line's first byte. Line-break glyphs +// are absent from the arrays, as in the real renderer. +func touchSelStateLines(content string) { + TheState = NewState() + TheState.Editor.Buffer = content + TheState.Editor.CursorPosition = len(content) + TheState.EditorRegion = ui.Region{X: 16, Y: 100, W: 379, H: 700} + TheState.ScrollOffset = 0 + TheState.Editor.IMEWindowStartByte = 0 + TheState.Editor.IMEWindowText = content + lh := ui.Dp(16.8) + layout := ui.GlyphLayout{ + LineHeight: lh, + VisualLineStarts: []int{0}, + } + line, col := 0, 0 + for i := 0; i < len(content); i++ { + if content[i] == '\n' { + layout.VisualLineStarts = append(layout.VisualLineStarts, i+1) + line, col = line+1, 0 + continue + } + layout.ByteOffsets = append(layout.ByteOffsets, i) + layout.X = append(layout.X, ui.Dp(10*col)) + layout.Y = append(layout.Y, ui.Dp(float64(lh)*float64(line))) + layout.Advance = append(layout.Advance, 10) + col++ + } + TheState.Editor.GlyphLayout = layout +} + +// TestTextPos_EmptyLine is a regression test for selection anchors snapping +// back to a single line when dragged across an empty line: a glyph-less +// line has exactly one insertion point, its first byte, and must map there +// (the old code clamped to the LAST non-empty line, so the anchor landed on +// a neighbouring text line at the finger's x and the selection collapsed). +func TestTextPos_EmptyLine(t *testing.T) { + touchSelStateLines("abc\n\ndef\n") + lh := float64(EffectiveLineHeight()) // 16.8 + // Empty line 1's band: y in [16.8, 33.6). + pos, ok := textPosFromLocalPoint(5, 1.5*lh) + if !ok || pos != 4 { + t.Errorf("textPosFromLocalPoint(5, 25.2) = %d, %v; want 4 (the empty line's lone insertion point)", pos, ok) + } + // The trailing empty line (after the last "\n") maps to EOF (byte 9). + pos, ok = textPosFromLocalPoint(5, 3.5*lh) + if !ok || pos != 9 { + t.Errorf("textPosFromLocalPoint(5, 58.8) = %d, %v; want 9 (EOF)", pos, ok) + } + // Past the end of line 0's text: the line's terminating newline, byte 3. + pos, ok = textPosFromLocalPoint(29, 0.5*lh) + if !ok || pos != 3 { + t.Errorf("textPosFromLocalPoint(29, 8.4) = %d, %v; want 3 (end of line 0)", pos, ok) + } + // Mid line 2: unchanged line behaviour (the 'e' of "def"). + pos, ok = textPosFromLocalPoint(15, 2.5*lh) + if !ok || pos != 6 { + t.Errorf("textPosFromLocalPoint(15, 42) = %d, %v; want 6", pos, ok) + } +} + +// TestTextPos_EmptyFirstLine is the user-visible regression for the caret +// jumping to an adjacent line: when the shaped WINDOW starts on an empty +// line (no glyphs on visual line 0), the old (Y-minY)/lineHeight grouping +// numbered every glyph line one too high, so a tap on visual line N landed +// on line N+1 — the caret appeared one line below the finger, and it +// "jumped back" when the window scrolled past the empty line. +func TestTextPos_EmptyFirstLine(t *testing.T) { + // Window text starting on an empty line (line 0), as when the viewport + // top sits on an empty line: "\nabc\ndef\n" — line 0 empty (byte 0), + // line 1 "abc" (bytes 1-3, "\n" at 4), line 2 "def" (bytes 5-7, "\n" at 8). + touchSelStateLines("\nabc\ndef\n") + lh := float64(EffectiveLineHeight()) // 16.8 + + // Tap on window line 1 (the "abc" line): the byte must be on line 1, + // not line 2 (the old off-by-one landing). + pos, ok := textPosFromLocalPoint(5, 1.5*lh) + if !ok || pos != 1 { + t.Errorf("tap on line 1 = %d, %v; want 1 (line 1's first glyph; the old code gave 5, line 2)", pos, ok) + } + // Tap past the end of line 1's text: line 1's terminating "\n" (byte 4), + // not line 2's. + pos, ok = textPosFromLocalPoint(29, 1.5*lh) + if !ok || pos != 4 { + t.Errorf("tap right of line 1 = %d, %v; want 4 (line 1 end); the old code gave 8 (line 2 end)", pos, ok) + } + // Tap on the empty FIRST line: its lone insertion point, byte 0 (the old + // code clamped onto line 1's text). + pos, ok = textPosFromLocalPoint(5, 0.5*lh) + if !ok || pos != 0 { + t.Errorf("tap on empty line 0 = %d, %v; want 0 (the empty line's lone insertion point)", pos, ok) + } + // Tap on window line 2: unaffected beyond the renumbering fix — line 2's + // first glyph (byte 5). + pos, ok = textPosFromLocalPoint(5, 2.5*lh) + if !ok || pos != 5 { + t.Errorf("tap on line 2 = %d, %v; want 5", pos, ok) + } +} + +// TestSelDrag_EndHandleAcrossEmptyLine is the user-visible regression: +// dragging the end handle down across an empty line must sweep the selection +// up to the empty line (its lone insertion point) and then continue onto +// the text after it — not snap back to a single line. +func TestSelDrag_EndHandleAcrossEmptyLine(t *testing.T) { + touchSelStateLines("abc\n\ndef\n") + HandleLongPressAt(16+5, 100+8) // selects "abc" [0,3) + assertSelection(t, 0, 3) + // Grab the end handle: the anchor is at byte 3 (end of line 0: x=30, + // y=8.4, the line's middle). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 8.4}) + assertSelection(t, 0, 3) + // Drag down into the empty line's band: the end sweeps to the empty + // line's insertion point (byte 4) instead of snapping back. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 25.2}) + assertSelection(t, 0, 4) + // Continue onto line 2: the end follows the finger to the end of "def" + // (byte 8). + HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 42}) + assertSelection(t, 0, 8) + HandleSelDragEvt(ui.SelectionDragEnd{}) + // Grab the start handle at byte 0 (line 0: x=0, y=8.4) and drag it down + // onto the empty line: it sweeps to the empty line's insertion point + // (byte 4) and the selection shrinks from [0,8) to [4,8) — no collapse, + // no snap back. + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 8.4}) + HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 25.2}) + assertSelection(t, 4, 8) + HandleSelDragEvt(ui.SelectionDragEnd{}) +} diff --git a/internal/editor/wrap_apply_test.go b/internal/editor/wrap_apply_test.go index 4e0398e..1f164f3 100644 --- a/internal/editor/wrap_apply_test.go +++ b/internal/editor/wrap_apply_test.go @@ -203,16 +203,3 @@ func byteOffsetOfLine(content string, i int) int { return o } -// lineSpan returns the total byte length of the first n lines (including -// their terminators) of content. -func lineSpan(content string, n int) int { - o := 0 - for line := 0; line < n; line++ { - nx := strings.IndexByte(content[o:], '\n') - if nx < 0 { - return len(content) - o - } - o += nx + 1 - } - return o -} diff --git a/internal/editor/wrap_mapping_test.go b/internal/editor/wrap_mapping_test.go index c256029..1022374 100644 --- a/internal/editor/wrap_mapping_test.go +++ b/internal/editor/wrap_mapping_test.go @@ -197,10 +197,8 @@ func newTestBufferForWrap(n int, counts []int32) *ChunkedBuffer { } cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(buf))) w := NewWrapIndex(len(offsets)) - if counts != nil { - for i, c := range counts { - w.Set(i, c) - } + for i, c := range counts { + w.Set(i, c) } cb.WrapIndex = w diff --git a/internal/io/pool/mock/filesystem.go b/internal/io/pool/mock/filesystem.go index 5b3c174..43311c0 100644 --- a/internal/io/pool/mock/filesystem.go +++ b/internal/io/pool/mock/filesystem.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -534,7 +535,7 @@ func (fs *FileSystem) ListPaths(prefix string) []string { var paths []string for path := range fs.files { - if prefix == "" || filepath.HasPrefix(path, prefix) { + if prefix == "" || strings.HasPrefix(path, prefix) { paths = append(paths, path) } } diff --git a/internal/io/pool/worker_pool.go b/internal/io/pool/worker_pool.go index b33c59a..c280ea6 100644 --- a/internal/io/pool/worker_pool.go +++ b/internal/io/pool/worker_pool.go @@ -322,29 +322,3 @@ func (wp *WorkerPool) untrackPending(task Task) { } } } - -// taskWrapper adds an ID to a task for tracking results. -type taskWrapper struct { - id int64 - original Task -} - -func (tw *taskWrapper) Execute() Result { - return tw.original.Execute() -} - -func (tw *taskWrapper) Priority() Priority { - return tw.original.Priority() -} - -func (tw *taskWrapper) TaskID() string { - return tw.original.TaskID() -} - -func (tw *taskWrapper) TaskType() TaskType { - return tw.original.TaskType() -} - -func (tw *taskWrapper) DirPath() string { - return tw.original.DirPath() -} diff --git a/internal/test/e2e/scroll_cursor_test.go b/internal/test/e2e/scroll_cursor_test.go index 75e4471..dc2319f 100644 --- a/internal/test/e2e/scroll_cursor_test.go +++ b/internal/test/e2e/scroll_cursor_test.go @@ -106,3 +106,96 @@ func TestEditorClickToMoveCursorWithScroll(t *testing.T) { t.Errorf("expected cursor to move to 7 (Line 2), but got %d", pos) } } + +// TestRealFile_CaretCursorOutsideWindow pins the off-screen caret contract: +// when the user scrolls the caret's line out of the viewport, the shaped +// window no longer contains the cursor's byte, and the frame must carry the +// TRUE window-relative cursor position (negative when the cursor is above +// the window, past len(Value) when below) so the renderer can skip the caret +// entirely. +// +// Pre-fix, EditorLayout clamped a negative window-relative cursor to 0 and +// drawWrappedText drew the caret unconditionally: scrolling the caret's line +// just past the top of the viewport made the caret "jump" onto the first +// visible line, then snap back to its real line when the user scrolled back +// (the reported cursor-jumps-to-adjacent-line bug). Native Android simply +// does not draw a caret that is off-screen. +func TestRealFile_CaretCursorOutsideWindow(t *testing.T) { + const ( + lines = 60 + lineLen = 21 + scrollLn = 10 + ) + h, _ := realFileHarness(t, "caret_window.txt", uniformContent(lines)) + defer h.Cleanup() + + h.SendConfig(780, 400) + if _, err := h.WaitForFrame(5 * time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + + // Scroll so the window starts at line 10 (byte 210); put the caret on + // line 5 (byte 105), ABOVE the window. + caretAbove := 5 * lineLen + if err := h.WithState(func(st *editor.State) { + st.ScrollOffset = ui.Dp(16.8*scrollLn + 5) + st.Editor.CursorPosition = caretAbove + }); err != nil { + t.Fatalf("WithState: %v", err) + } + prev := h.FrameCount() + h.SendConfig(780, 400) // state changes alone do not emit frames + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for scrolled frame: %v", err) + } + tf, ok := lastEditorTextField(t, h) + if !ok { + t.Fatal("no editor_text TextField in latest frame") + } + if want := caretAbove - scrollLn*lineLen; tf.CursorPosition != want { + t.Fatalf("window cursor above = %d, want %d (negative: cursor above window, caret must be hidden)", tf.CursorPosition, want) + } + + // Mirror case: caret on line 50 (byte 1050), BELOW the window (the + // ~18-line window at line 10 ends far short of it). + caretBelow := 50 * lineLen + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = caretBelow + }); err != nil { + t.Fatalf("WithState: %v", err) + } + prev = h.FrameCount() + h.SendConfig(780, 400) + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for scrolled frame (below): %v", err) + } + tf, ok = lastEditorTextField(t, h) + if !ok { + t.Fatal("no editor_text TextField in latest frame") + } + if want := caretBelow - scrollLn*lineLen; tf.CursorPosition != want { + t.Fatalf("window cursor below = %d, want %d (past len(Value): cursor below window, caret must be hidden)", tf.CursorPosition, want) + } + if tf.CursorPosition <= len(tf.Value) { + t.Fatalf("window cursor %d should be past window end %d", tf.CursorPosition, len(tf.Value)) + } + + // And the in-window case must still carry the plain relative offset. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 12*lineLen + 9 + }); err != nil { + t.Fatalf("WithState: %v", err) + } + prev = h.FrameCount() + h.SendConfig(780, 400) + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for scrolled frame (inside): %v", err) + } + tf, ok = lastEditorTextField(t, h) + if !ok { + t.Fatal("no editor_text TextField in latest frame") + } + if want := 12*lineLen + 9 - scrollLn*lineLen; tf.CursorPosition != want { + t.Fatalf("window cursor inside = %d, want %d", tf.CursorPosition, want) + } +} diff --git a/internal/test/e2e/touch_selection_e2e_test.go b/internal/test/e2e/touch_selection_e2e_test.go index d91f9e9..27af750 100644 --- a/internal/test/e2e/touch_selection_e2e_test.go +++ b/internal/test/e2e/touch_selection_e2e_test.go @@ -127,10 +127,12 @@ func TestRealFile_TouchSelectionDragEndHandle(t *testing.T) { t.Fatalf("wait frame: %v", err) } - // Drag the end handle (which=1) past the last glyph: selects the line. + // Drag the end handle (which=1): grab near the anchor (byte 5 at local + // x=50), then move past the last glyph — the anchor follows the finger + // 1:1 and selects the line. before = h.FrameCount() h.SendInput([]ui.InputEvent{ - {Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, Y: reg.Y + 8}}, + {Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 52, Y: reg.Y + 8}}, {Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, Y: reg.Y + 8}}, {Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEnd{}}, }) diff --git a/internal/ui/caret_point_test.go b/internal/ui/caret_point_test.go new file mode 100644 index 0000000..1a87cae --- /dev/null +++ b/internal/ui/caret_point_test.go @@ -0,0 +1,153 @@ +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) + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index f0a6bad..c94ae23 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -234,7 +234,15 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { } r.lastIMEField = tf.id r.lastWasFocused = true - gtx.Execute(key.FocusCmd{Tag: tf.id}) + // Issue key.FocusCmd only on a focus transition (see + // Renderer.lastFocusCmdID): a per-frame no-op FocusCmd still triggers + // the router's immediate-command path, which re-queues the frame's + // pointer events and replays every touch event into every gesture. + r.focusSeenThisFrame = true + if r.lastFocusCmdID != tf.id { + r.lastFocusCmdID = tf.id + gtx.Execute(key.FocusCmd{Tag: tf.id}) + } // Raise the soft keyboard only when the logic layer pulses it (focus // gain, file open, editor tap), not every frame. A per-frame show was // harmless while the app only redrew on user events, but the IME @@ -293,13 +301,23 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { selEnd = runeCount(tf.Value, tf.CursorPosition) } if selStart != r.lastSelStart || selEnd != r.lastSelCaret { - r.lastSelStart = selStart - r.lastSelCaret = selEnd - rng := key.Range{Start: selStart, End: selEnd} - if selStart < 0 { - rng = key.Range{Start: selEnd, End: selEnd} + // While a selection/caret handle drag is in progress, defer the + // IME selection sync (see Renderer.selDragActive): pushing a + // SelectionCmd every frame the drag moves the selection triggers the + // input router's immediate-command replay of the frame's pointer + // events, which re-injects the drag into every gesture and makes the + // selection jump. The final selection is pushed on the first frame + // after the drag ends (lastSelStart/lastSelCaret were not updated, + // so the mismatch persists until then). + if !r.selDragActive { + r.lastSelStart = selStart + r.lastSelCaret = selEnd + rng := key.Range{Start: selStart, End: selEnd} + if selStart < 0 { + rng = key.Range{Start: selEnd, End: selEnd} + } + gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: rng, Caret: key.Caret{}}) } - gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: rng, Caret: key.Caret{}}) } } else if r.lastIMEField == tf.id { // This (previously-focused) field lost focus: forget it so the next focus diff --git a/internal/ui/render.go b/internal/ui/render.go index 7d98489..1745e2f 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -92,6 +92,20 @@ type Renderer struct { lastSelCaret int // window-relative rune index of last-pushed selection end/caret lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true} + // FocusCmd dedup (main-owned, persistent across frames). key.FocusCmd is + // issued ONLY on a focus transition, never per frame: even a no-op FocusCmd + // (same focus) takes the router's "immediate command" path, which re-queues + // the frame's pending pointer events and re-delivers every touch event to + // every gesture. During a selection-handle drag that replayed each event + // several times per frame, making the selection unusable. The key queue + // keeps the focus until the handler stops registering as focusable, so one + // command per transition is sufficient. + lastFocusCmdID string + // focusSeenThisFrame is reset at the start of each Draw; if no focused + // TextField was drawn in the frame, lastFocusCmdID is cleared so a field + // regaining focus later re-issues the command. + focusSeenThisFrame bool + // Long-press detection. pressProbe is a plain event tag observing raw // pointer events inside the editor text region: gesture.Click reports // nothing until release, so a long press (finger held still for @@ -124,6 +138,15 @@ type Renderer struct { // (-1 = none), set by CheckGestures and read by drawWrappedText to // enlarge the grabbed handle, as the framework does while dragging. selDraggingWhich int + // selDragActive reports that a selection/caret drag is in progress. + // TextField.Draw skips the key.SelectionCmd IME sync while it is set: + // the command triggers the router's immediate-command path, which + // re-queues the frame's pointer events and replays every drag event + // into the gestures (a replay storm — each re-queued event lands in + // q.changes and is re-queued again on the next SelectionCmd). The IME + // only needs the final selection, pushed on the first frame after the + // drag ends. + selDragActive bool // gestureExclusions holds the selection-handle grab boxes (view-local // px, [x0, y0, x1, y1]) collected by drawWrappedText for the current // frame. On Android the main loop forwards them to @@ -136,6 +159,19 @@ type Renderer struct { // frame (see gestureExclusions). func (r *Renderer) GestureExclusions() [][4]int { return r.gestureExclusions } +// pointInHandleBox reports whether the window-pixel point lies inside one of +// the last frame's selection/caret handle grab boxes (gestureExclusions holds +// those boxes, clipped to the editor region). +func (r *Renderer) pointInHandleBox(p image.Point) bool { + for _, b := range r.gestureExclusions { + // Boxes are [x0, y0, x1, y1]. + if p.X >= b[0] && p.X < b[2] && p.Y >= b[1] && p.Y < b[3] { + return true + } + } + return false +} + // New creates a new Renderer. func New(th Theme, shp *text.Shaper) *Renderer { r := &Renderer{ @@ -201,6 +237,7 @@ func (r *Renderer) toDp(px Px) Dp { // reads logic state directly). func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) { r.scale = scale + r.focusSeenThisFrame = false // per-frame reset for FocusCmd dedup // Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min. winW := gtx.Constraints.Max.X winH := gtx.Constraints.Max.Y @@ -216,6 +253,13 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) { r.drawElement(gtx, e) } + // If no focused TextField was drawn this frame, the key queue will drop + // the focus (the focused handler stops registering as focusable). Clear the + // dedup so the same field re-issues key.FocusCmd when it regains focus. + if !r.focusSeenThisFrame { + r.lastFocusCmdID = "" + } + clipRect.Pop() } @@ -319,6 +363,16 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { if reg.longFired { break // the press was consumed as a long press } + if r.pointInHandleBox(evt.Position) { + // A tap inside a handle grab box is a handle touch that + // never reached the drag slop (or a deliberate light + // touch on the handle): it must be a no-op, not a text + // tap. Forwarding it would clear the selection and move + // the caret, so a light touch on a handle destroyed the + // selection. (Native Android: tapping a handle does + // nothing.) + break + } if evt.NumClicks >= 2 { events = append(events, InputEvent{ Handler: reg.handler, @@ -364,8 +418,18 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { } switch e.Kind { case pointer.Drag: + // Forward only grabbed events: gesture.Drag also returns the + // pre-grab (Shared priority) moves, and acting on those would + // start moving the selection at the press position — the grab + // jitter. The first Grabbed event lands just past the touch slop, + // matching native Android, where the handle follows only after + // the slop. + if e.Priority != pointer.Grabbed { + continue + } r.selDragEmitting[which] = true r.selDraggingWhich = which + r.selDragActive = true events = append(events, InputEvent{ Handler: r.selDragHandler, Data: SelectionDragEvent{Which: which, X: r.toDp(Px(e.Position.X)), Y: r.toDp(Px(e.Position.Y))}, @@ -384,6 +448,21 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { } } } + // Safety net: if no gesture is still dragging but a selection/caret drag + // was active (e.g. the release was never delivered), clear the flag so the + // IME selection sync (key.SelectionCmd) resumes — see TextField.Draw. + if r.selDragActive { + stillDragging := false + for _, d := range drags { + if d.Dragging() { + stillDragging = true + break + } + } + if !stillDragging { + r.selDragActive = false + } + } for _, reg := range r.scrolls { // gesture.Scroll.Update returns scroll delta in pixels. // ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet). @@ -397,6 +476,7 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { }) } } + return events } @@ -735,10 +815,21 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w // Record layout data for this glyph. // g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp. // g.Y is the baseline in device pixels. - layout.ByteOffsets = append(layout.ByteOffsets, byteOffset) - layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale)) - layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale)) - layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale)) + // The shaper flags the LAST glyph of every visual line with + // FlagLineBreak. For hard lines that is the zero-width "\n" cluster + // glyph (and, after a trailing "\n", one more synthetic end-of-text + // glyph) — not a character, so it is skipped to keep ByteOffsets a 1:1 + // map to bytes. For SOFT-wrapped lines (and a final line without a + // trailing newline) the flag instead sits on the line's last visible + // character, which MUST stay in the layout: dropping it would make taps + // on the right half of that character and selection highlights of it + // miss. Zero width is the discriminator (a real glyph always advances). + if g.Flags&text.FlagLineBreak == 0 || g.Advance != 0 { + layout.ByteOffsets = append(layout.ByteOffsets, byteOffset) + layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale)) + layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale)) + layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale)) + } // Advance byteOffset by g.Runes. for i := uint16(0); i < g.Runes; i++ { @@ -794,32 +885,35 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w call := m.Stop() call.Add(gtx.Ops) - // Determine cursor position from `layout` and `cursorPos` - var cursorX, cursorY Dp - if len(layout.ByteOffsets) == 0 { - cursorX = reg.X - cursorY = reg.Y - } else { - idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { - return layout.ByteOffsets[i] >= cursorPos - }) - if idx < len(layout.ByteOffsets) { - cursorX = reg.X + layout.X[idx] - 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] - ascent - } + // caretPoint maps a window-relative byte offset to the insertion + // point's region-relative (x, y) in Dp, where y is the line's baseline + // (see CaretPoint). + caretPoint := func(byteOff int) (x, y Dp) { + return CaretPoint(layout, str, byteOff, ascent, lineH) } - // Draw the cursor (thin vertical bar) - cursorRegion := Region{ - X: cursorX, - Y: cursorY, - W: Dp(2), - H: lineH, // line height (font-scale aware) + // Draw the caret only when the cursor's byte is inside the shaped window + // (window-relative: [0, len(str)]). The window covers the viewport + // exactly, so an out-of-range cursor is off-screen and its caret must not + // be drawn; the caller used to clamp it to 0, which made the caret jump + // onto the top (or, past the end, the bottom) visible line whenever the + // user scrolled past it. The boundary values are on-screen: 0 is the + // window's first byte and len(str) is the window's last insertion point. + if cursorPos >= 0 && cursorPos <= len(str) { + // Determine cursor position from `layout` and `cursorPos` + cursorX, cursorY := caretPoint(cursorPos) + cursorX = reg.X + cursorX + cursorY = reg.Y - scrollOffset + cursorY - ascent + + // Draw the cursor (thin vertical bar) + cursorRegion := Region{ + X: cursorX, + Y: cursorY, + W: Dp(2), + H: lineH, // line height (font-scale aware) + } + r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255}) } - r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255}) // Long-press probe and selection/caret drag handles. The probe op and the // (clipped) drag registrations live in the text clip so they only respond @@ -831,14 +925,8 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w // handleAt mirrors the cursor computation above: window-relative byte // offset -> screen Dp of the caret insertion point. handleAt := func(byteOff int) (x, y Dp) { - idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { - return layout.ByteOffsets[i] >= byteOff - }) - if idx < len(layout.ByteOffsets) { - 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] - ascent + hx, hy := caretPoint(byteOff) + return reg.X + hx, reg.Y - scrollOffset + hy - ascent } // The visual handle (drawHandle) is a ~20dp teardrop centred at // (hx, hy+lineH+handleRadius); the GRAB region is a 48dp box around @@ -1024,3 +1112,74 @@ func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, widt scale.Pop() offset.Pop() } + +// CaretPoint maps a window-relative byte offset to the insertion point's +// (x, y) in Dp relative to the shaped window's origin, where y is the +// line's baseline. A byte at a real glyph's start sits at the glyph's left +// edge; any other insertion point (a line's terminating "\n", an empty +// line's lone byte, EOF, or a wrapped line's first byte) sits on the visual +// line whose first byte is at or before it — the last such line — at the +// line origin for a line's first byte, and at the last glyph's right edge +// otherwise. (The first glyph at/past such a byte sits on the NEXT line, +// so it cannot be used for the line resolution.) +// +// The line's baseline comes from the uniform shaper grid +// (firstBaseline + line*lineH). firstBaseline is the window's FIRST visual +// line's baseline. When that line is empty (no recorded glyphs), the +// smallest recorded Y is the first NON-empty line's baseline = +// firstBaseline + j*lineH, j being the first recorded glyph's visual line; +// anchoring on the smallest Y instead drew every boundary caret one line +// too low per leading empty line, and the caret visibly jumped a line when +// the window scrolled past the empty line. +func CaretPoint(layout GlyphLayout, str string, byteOff int, ascent, lineH Dp) (x, y Dp) { + if idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { + return layout.ByteOffsets[i] >= byteOff + }); idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] == byteOff { + return layout.X[idx], layout.Y[idx] + } + line := 0 + lineStart, lineEnd := 0, len(str) + if starts := layout.VisualLineStarts; len(starts) > 0 { + k := sort.Search(len(starts), func(i int) bool { + return starts[i] > byteOff + }) + if k > 0 { + line = k - 1 + } + lineStart = starts[line] + if k < len(starts) { + lineEnd = starts[k] + } + } + var anchor Dp + if len(layout.ByteOffsets) > 0 { + j := 0 + if starts := layout.VisualLineStarts; len(starts) > 0 { + if k := sort.Search(len(starts), func(i int) bool { + return starts[i] > layout.ByteOffsets[0] + }); k > 0 { + j = k - 1 + } + } + anchor = layout.Y[0] - Dp(j)*lineH + } else { + anchor = ascent // no glyphs in the window (pure newlines) + } + y = anchor + Dp(line)*lineH + if byteOff == lineStart { + return 0, y // line origin (empty line, or wrapped line start) + } + // The line's "\n" (or EOF on the last line): the last glyph's right + // edge on the line. + var rightX Dp + found := false + for i, bo := range layout.ByteOffsets { + if bo < lineStart || bo >= lineEnd { + continue + } + if xe := layout.X[i] + layout.Advance[i]; !found || xe > rightX { + rightX, found = xe, true + } + } + return rightX, y +} diff --git a/scripts/build_emu.sh b/scripts/build_emu.sh index 1fc38b2..308f529 100755 --- a/scripts/build_emu.sh +++ b/scripts/build_emu.sh @@ -37,6 +37,9 @@ command -v apksigner >/dev/null 2>&1 || die "apksigner not found (need $ANDROID_ command -v adb >/dev/null 2>&1 || die "adb not found (need $ANDROID_HOME/platform-tools)" [ -f "$HOME/.android/debug.keystore" ] || die "debug keystore missing: $HOME/.android/debug.keystore" +echo "=== static checks (go vet + staticcheck) ===" +"$REPO/scripts/check.sh" + # apktool: auto-download to a stable location if missing (never /tmp, which # gets wiped between sessions). APKTOOL="$ANDROID_HOME/tools/apktool.jar" diff --git a/scripts/build_phone.sh b/scripts/build_phone.sh index d9be45f..45a11a7 100755 --- a/scripts/build_phone.sh +++ b/scripts/build_phone.sh @@ -31,6 +31,9 @@ command -v apksigner >/dev/null 2>&1 || die "apksigner not found (need $ANDROID_ command -v adb >/dev/null 2>&1 || die "adb not found (need $ANDROID_HOME/platform-tools)" [ -f "$HOME/.android/debug.keystore" ] || die "debug keystore missing: $HOME/.android/debug.keystore" +echo "=== static checks (go vet + staticcheck) ===" +"$REPO/scripts/check.sh" + APKTOOL="$ANDROID_HOME/tools/apktool.jar" if [ ! -f "$APKTOOL" ]; then echo "=== apktool missing; downloading v3.0.3 to $APKTOOL ===" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..7b5709d --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Static analysis gate for release builds. +# +# Runs go vet and staticcheck (all default checks, including U1000 for dead +# code) over the whole module. A non-zero exit fails the release. +# +# Usage: ./scripts/check.sh +# +# Prefers a staticcheck binary on PATH (e.g. ~/go/bin/staticcheck) so +# repeated runs are fast; falls back to `go run` with a pinned version. +set -euo pipefail + +REPO=$(cd "$(dirname "$0")/.." && pwd) +cd "$REPO" + +# v0.7.0 cannot read Go 1.27's export data ("export data version 4 is +# greater than maximum supported version 2"); v0.8.0-rc.1 is the first +# release that can. Re-pin to stable v0.8.0 once it lands. +STATICCHECK_VERSION="v0.8.0-rc.1" + +echo "=== go vet ===" +go vet ./... + +echo "=== staticcheck ===" +if command -v staticcheck >/dev/null 2>&1; then + staticcheck ./... +else + go run honnef.co/go/tools/cmd/staticcheck@"$STATICCHECK_VERSION" ./... +fi + +echo "=== checks passed ==="