Handle drags: 1:1 finger tracking with cross-flip; fix caret/taps on empty lines

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.
This commit is contained in:
Greg Pomerantz 2026-08-19 22:34:12 -04:00
parent 110c92e3fa
commit d8b5bc704b
21 changed files with 955 additions and 377 deletions

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)

View File

@ -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()

View File

@ -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 {

View File

@ -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 }
}
}

View File

@ -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()
}

View File

@ -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

View File

@ -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{})
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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{}},
})

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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 ==="

31
scripts/check.sh Executable file
View File

@ -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 ==="