diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 6c9a59fc..ee309562 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -125,6 +125,13 @@ extension SuggestionCoordinator { self?.recordSuggestionAcceptedIfFirstChunk(of: sessionForAcceptance) } + // The insert just made every geometry cache built from pre-insert reads stale: child-run + // hosts would otherwise map the published caret into pre-insert run frames for up to a + // throttle window. Stamp the acceptance so the stability gate can scope its + // backward-drift hold to the frames-catching-up window. + lastAcceptanceAt = Date() + focusModel.invalidateTransientCaretCaches() + cancelPredictionWork() switch interactionState.commitAcceptedChunk( @@ -357,6 +364,8 @@ extension SuggestionCoordinator { return false } + lastAcceptanceAt = Date() + focusModel.invalidateTransientCaretCaches() cancelPredictionWork() latestGenerationNumber = session.baseContext.generation clearSuggestion(clearDiagnostics: false) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 8e259fdd..d479cd4f 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -550,6 +550,7 @@ extension SuggestionCoordinator { return } + focusModel.invalidateTransientCaretCaches() cancelPredictionWork() clearSuggestion(clearDiagnostics: false) hideOverlay(reason: "Overlay hidden because Cotabby automatically fixed a typo.") @@ -924,7 +925,10 @@ extension SuggestionCoordinator { newFocusChangeSequence: liveContext.focusChangeSequence, // While the host has not published our own synthetic insert, this snapshot's caret is // the pre-insertion one; re-anchoring to it is the left-then-right accept jitter. - isAwaitingPostInsertionSync: interactionState.isAwaitingPostInsertionSync + isAwaitingPostInsertionSync: interactionState.isAwaitingPostInsertionSync, + millisecondsSinceLastAcceptance: lastAcceptanceAt.map { + Int(Date().timeIntervalSince($0) * 1000) + } ) { presentOverlay( text: reconciledSession.remainingText, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 2282505c..f8a42fdc 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -133,6 +133,11 @@ final class SuggestionCoordinator: ObservableObject { /// accept on the last word. See `SuggestionSessionReconciler.isStaleAcceptanceEcho`. var lastAcceptedTail: AcceptedSuggestionTail? + /// Wall-clock moment of the most recent committed acceptance. The stability gate uses its age + /// to scope the backward-drift hold: only geometry read shortly after our own insert can be + /// the stale-frame kind, so older backward corrections stay re-anchorable. + var lastAcceptanceAt: Date? + /// Bounded string-only memory of recent suggestions for instant re-show on rollback and /// re-entry (see `SuggestionAnchorCache`). `cotabbyAnchorReuseDisabled` is the kill switch. var suggestionAnchorCache = SuggestionAnchorCache() diff --git a/Cotabby/Models/FocusModels.swift b/Cotabby/Models/FocusModels.swift index b14433a5..89bf5a3c 100644 --- a/Cotabby/Models/FocusModels.swift +++ b/Cotabby/Models/FocusModels.swift @@ -121,7 +121,7 @@ struct FocusInspectionSnapshot: Equatable { /// Every field is optional: any attribute the host does not expose stays nil and the overlay falls /// back to its default styling. Stored as plain value types (no `NSFont`/`NSColor`) so the snapshot /// stays `Equatable`/`Sendable` and is cheap to carry across async boundaries. -struct ResolvedFieldStyle: Equatable, Sendable { +nonisolated struct ResolvedFieldStyle: Equatable, Sendable { /// PostScript font name suitable for `NSFont(name:size:)`. let fontName: String? /// Host-reported point size, used only as the reference for scale-invariant metric sizing. @@ -148,7 +148,7 @@ nonisolated struct ObservedContentEdges: Equatable, Sendable { /// This snapshot is the future handoff point into suggestion generation. /// We store enough information to understand text context and caret placement without generating yet. -struct FocusedInputSnapshot: Equatable { +nonisolated struct FocusedInputSnapshot: Equatable { let applicationName: String let bundleIdentifier: String let processIdentifier: Int32 diff --git a/Cotabby/Models/FocusTrackingModel.swift b/Cotabby/Models/FocusTrackingModel.swift index 691b8af8..06b24f58 100644 --- a/Cotabby/Models/FocusTrackingModel.swift +++ b/Cotabby/Models/FocusTrackingModel.swift @@ -71,6 +71,12 @@ final class FocusTrackingModel: ObservableObject { tracker.refreshNow() } + /// Forwards the coordinator's "I just mutated the focused field" hint so resolver caches that + /// predate the mutation cannot serve stale geometry to the next capture. + func invalidateTransientCaretCaches() { + tracker.invalidateTransientCaretCaches() + } + /// Updates the AX polling interval at runtime. Restarts the timer if already running. func updatePollInterval(milliseconds: Int) { tracker.updatePollInterval(TimeInterval(milliseconds) / 1000.0) diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index 79655f7c..ecb1fc23 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -32,6 +32,11 @@ protocol SuggestionFocusProviding: AnyObject { var millisecondsSinceLastCapture: Int? { get } func refreshNow() + + /// Hint that Cotabby just mutated the focused field itself (synthetic insert or replace), so + /// provider-side caches built from pre-mutation reads must not serve the next capture. + /// Providers without such caches use the default no-op. + func invalidateTransientCaretCaches() } extension SuggestionFocusProviding { @@ -39,6 +44,9 @@ extension SuggestionFocusProviding { /// providers report a real age; test fakes can ignore freshness entirely. var millisecondsSinceLastCapture: Int? { nil } + /// Default: nothing cached, nothing to invalidate. + func invalidateTransientCaretCaches() {} + /// Refreshes only when the last capture is older than `maxAgeMilliseconds`. The suggestion /// pipeline performs several captures per keystroke (host-publish poll, post-debounce check, /// result apply); when two of those land within one debounce window the second read cannot diff --git a/Cotabby/Services/Focus/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/AXTextGeometryResolver.swift index ace149c1..cfa744e3 100644 --- a/Cotabby/Services/Focus/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/AXTextGeometryResolver.swift @@ -282,7 +282,16 @@ struct AXTextGeometryResolver { } let runFrame = cocoaRunFrames[placement.runIndex] - let caretX = runFrame.minX + placement.fraction * runFrame.width + var caretX = runFrame.minX + placement.fraction * runFrame.width + // The parent value extends past the matched runs (text published, frames not yet + // reflowed): extend the estimate by the measured per-character advance instead of parking + // the caret at the stale trailing edge, which sat a full inserted-word left of the truth + // and bounced the overlay on the fresh walk. The overlay layout clamps to the usable + // frame at present time, so a wrap the frames cannot show yet over-extends rightward at + // worst, and the fresh walk settles it forward-only. + if placement.trailingGapCharacters > 0, let charWidth, charWidth > 0 { + caretX += charWidth * CGFloat(placement.trailingGapCharacters) + } return CaretGeometryResult( rect: CGRect(x: caretX, y: runFrame.minY, width: 2, height: runFrame.height), quality: .derived, @@ -324,8 +333,34 @@ struct AXTextGeometryResolver { /// Position inside the run: 0 is the leading edge, 1 the trailing edge. let fraction: CGFloat let mode: CaretRunMappingMode + /// Characters the caret sits past the run's trailing edge when the parent value has grown + /// beyond the matched runs: the signature of text the host has published but whose run + /// frames have not reflowed yet (Cotabby's own just-accepted insert, or fast typing at + /// the document end against throttled frames). Callers extend the X estimate by this many + /// measured character widths instead of parking the caret at the stale trailing edge, + /// which sat a full word left of the truth. Zero when the caret lies inside a run, the + /// gap spans a line break (extrapolating across one would be wrong), or the gap is too + /// large to extrapolate credibly. + let trailingGapCharacters: Int + + init( + runIndex: Int, + fraction: CGFloat, + mode: CaretRunMappingMode, + trailingGapCharacters: Int = 0 + ) { + self.runIndex = runIndex + self.fraction = fraction + self.mode = mode + self.trailingGapCharacters = trailingGapCharacters + } } + /// Beyond this many characters of unmatched trailing text, per-character extrapolation stops + /// being credible (a large paste reflows everything anyway); the placement falls back to the + /// trailing-edge snap and lets the next fresh walk correct it. + private static let maximumExtrapolatedGapCharacters = 64 + /// Internal (not private) so the mapping math is unit-testable without live AX elements. static func caretRunPlacement( runTexts: [String], @@ -346,7 +381,7 @@ struct AXTextGeometryResolver { let mode: CaretRunMappingMode = anchored.count == runTexts.count ? .aligned : .partiallyAligned - return placementAmongAnchors(anchored, caret: caret, mode: mode) + return placementAmongAnchors(anchored, caret: caret, mode: mode, parent: parent) } /// Anchors each run's text inside the parent value. Pass one accepts only boundary-clean @@ -396,11 +431,14 @@ struct AXTextGeometryResolver { /// Maps the caret offset onto the anchored runs: inside a range is proportional, inside a /// separator gap snaps to the nearest rendered edge (a line break or a blank line the runs /// cannot represent — either choice is at most one line from the truth, which text alone - /// cannot resolve), and beyond every anchor lands on the last run's trailing edge. + /// cannot resolve), and beyond every anchor lands on the last run's trailing edge, extended + /// by the unmatched trailing characters when they are extrapolable (see + /// `CaretRunPlacement.trailingGapCharacters`). private static func placementAmongAnchors( _ anchored: [(runIndex: Int, range: NSRange)], caret: Int, - mode: CaretRunMappingMode + mode: CaretRunMappingMode, + parent: NSString ) -> CaretRunPlacement { for (position, entry) in anchored.enumerated() { if caret < entry.range.location { @@ -408,7 +446,14 @@ struct AXTextGeometryResolver { let previous = anchored[position - 1] let previousEnd = previous.range.location + previous.range.length if caret - previousEnd <= entry.range.location - caret { - return CaretRunPlacement(runIndex: previous.runIndex, fraction: 1, mode: mode) + return CaretRunPlacement( + runIndex: previous.runIndex, + fraction: 1, + mode: mode, + trailingGapCharacters: extrapolableGapCharacters( + from: previousEnd, to: caret, in: parent + ) + ) } } return CaretRunPlacement(runIndex: entry.runIndex, fraction: 0, mode: mode) @@ -421,13 +466,33 @@ struct AXTextGeometryResolver { } } + let last = anchored[anchored.count - 1] return CaretRunPlacement( - runIndex: anchored[anchored.count - 1].runIndex, + runIndex: last.runIndex, fraction: 1, - mode: mode + mode: mode, + trailingGapCharacters: extrapolableGapCharacters( + from: last.range.location + last.range.length, to: caret, in: parent + ) ) } + /// The number of characters between a run's trailing edge and the caret when extending the + /// caret estimate by that many measured character widths is credible: a short, same-line gap. + /// A gap containing a line break renders on another line entirely (the snap is closer to the + /// truth there), and a huge gap means a reflow-everything edit no linear extension can model. + private static func extrapolableGapCharacters(from runEnd: Int, to caret: Int, in parent: NSString) -> Int { + let gap = caret - runEnd + guard gap > 0, gap <= maximumExtrapolatedGapCharacters else { + return 0 + } + let gapText = parent.substring(with: NSRange(location: runEnd, length: gap)) + guard !gapText.contains(where: \.isNewline) else { + return 0 + } + return gap + } + /// Maps non-breaking space variants to a plain space so matching survives hosts that mix the /// two between the parent value and run texts. Every replacement is a single UTF-16 unit for a /// single UTF-16 unit, so matched ranges stay valid coordinates in the original string. diff --git a/Cotabby/Services/Focus/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/FocusSnapshotResolver.swift index 5fb35929..ff2457eb 100644 --- a/Cotabby/Services/Focus/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/FocusSnapshotResolver.swift @@ -53,6 +53,14 @@ struct FocusSnapshotResolver { self.geometryResolver = geometryResolver ?? AXTextGeometryResolver() } + /// Drops the cached static-text-run walk so the next capture pays a fresh one. Called through + /// the focus provider after Cotabby's own synthetic insert: the cached run texts predate the + /// inserted chunk, and mapping the published caret against them lands a word left of the + /// truth (the accept-time jitter on child-run hosts). + func invalidateStaticRunWalkCache() { + staticRunWalkThrottle.invalidate() + } + /// Resolves the best editable candidate around the focused AX node and materializes a focus snapshot. /// /// `focusChangeSequence` is a monotonic counter owned by `FocusTracker`. The resolver threads diff --git a/Cotabby/Services/Focus/FocusTracker.swift b/Cotabby/Services/Focus/FocusTracker.swift index a4b739d3..911744c6 100644 --- a/Cotabby/Services/Focus/FocusTracker.swift +++ b/Cotabby/Services/Focus/FocusTracker.swift @@ -171,6 +171,12 @@ final class FocusTracker { rescheduleTimerIfIntervalChanged() } + /// Drops resolver caches whose contents Cotabby just made stale by mutating the focused field + /// itself (the static-run walk after a synthetic insert). The next capture pays fresh walks. + func invalidateTransientCaretCaches() { + snapshotResolver.invalidateStaticRunWalkCache() + } + /// Timer entry point: capture once, fold the result into idle backoff, then re-arm the timer at /// the backoff-derived interval. /// diff --git a/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift index 35ddeec1..d68f04d3 100644 --- a/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift +++ b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift @@ -26,6 +26,18 @@ final class StaticTextRunWalkThrottle { // no main-actor hop. Same workaround as `EmojiUsageStore` and `SystemMetricsStore`. nonisolated deinit {} + /// Drops the cached walk so the next caller pays a fresh one regardless of the window. + /// + /// Called after Cotabby's own synthetic insert: the cached run texts predate the inserted + /// chunk, so mapping the post-publish caret against them lands on the pre-insert position + /// (the accept-time jitter). Invalidation cannot fix the host's own reflow lag, but it + /// removes the up-to-one-interval of staleness this throttle would otherwise add on top. + func invalidate() { + lastSequence = nil + lastWalkAt = nil + cachedRuns = nil + } + /// Runs `walk` only when the throttle window elapsed or the focused field changed; otherwise /// returns the previous run list (including a cached empty result). `now` is injectable for /// tests. diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index cbecf86a..e1599dc0 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -253,8 +253,12 @@ final class OverlayController: SuggestionOverlayControlling { // pixel-identical ghost-width slide: its anchors are approximate either way, and observed // char-width hosts already correct through their own machinery. let shift: CGFloat - if geometry.caretQuality == .exact, - let hostAdvance = InsertedTextAdvance.width(of: insertedText, style: geometry.resolvedFieldStyle) { + if geometry.caretQuality == .exact || geometry.caretQuality == .derived, + let hostAdvance = InsertedTextAdvance.width( + of: insertedText, + observedCharWidth: geometry.observedCharWidth, + style: geometry.resolvedFieldStyle + ) { shift = hostAdvance } else { shift = GhostSuggestionLayout.renderedWidth(of: beforeText, font: renderFont) diff --git a/Cotabby/Support/InsertedTextAdvance.swift b/Cotabby/Support/InsertedTextAdvance.swift index 597510bc..36e29ea3 100644 --- a/Cotabby/Support/InsertedTextAdvance.swift +++ b/Cotabby/Support/InsertedTextAdvance.swift @@ -14,6 +14,24 @@ import AppKit /// keeps the anchor aligned with where AX will report the caret once the host publishes the /// insert, so post-accept reconciles have nothing to correct. nonisolated enum InsertedTextAdvance { + /// The caret's travel for `text` using the best host measurement available: the run-frame + /// average character width first (a direct measurement of the host's rendered glyphs, present + /// on child-run derived hosts), then the field's resolved font. Nil when neither exists, so + /// callers keep their previous approximation. + static func width( + of text: String, + observedCharWidth: CGFloat?, + style: ResolvedFieldStyle? + ) -> CGFloat? { + guard !text.isEmpty else { + return nil + } + if let observedCharWidth, observedCharWidth > 0 { + return observedCharWidth * CGFloat((text as NSString).length) + } + return width(of: text, style: style) + } + /// Width of `text` in the field's resolved font, or nil when the style does not carry a /// usable font (callers keep their previous approximation). /// diff --git a/Cotabby/Support/SuggestionAnchorCache.swift b/Cotabby/Support/SuggestionAnchorCache.swift index a87831b5..53bd283f 100644 --- a/Cotabby/Support/SuggestionAnchorCache.swift +++ b/Cotabby/Support/SuggestionAnchorCache.swift @@ -1,7 +1,7 @@ import Foundation /// One remembered suggestion, anchored to the text that preceded it. -struct SuggestionAnchor: Equatable { +nonisolated struct SuggestionAnchor: Equatable { /// `FocusedInputContext.focusedInputIdentityKey` of the field the suggestion belonged to. let identityKey: UInt64 /// The tail of `precedingText` at generation time (bounded; see `prefixTailLength`). diff --git a/Cotabby/Support/SuggestionOverlayStabilityGate.swift b/Cotabby/Support/SuggestionOverlayStabilityGate.swift index 4ca65e81..1ea67a14 100644 --- a/Cotabby/Support/SuggestionOverlayStabilityGate.swift +++ b/Cotabby/Support/SuggestionOverlayStabilityGate.swift @@ -50,13 +50,54 @@ enum SuggestionOverlayStabilityGate { /// poll tick snapped it back: the left-then-right accept jitter. While the field and text are /// unchanged, stale geometry is never worth re-anchoring to; the hold lasts at most one or two /// poll ticks because the sentinel clears the moment AX catches up. + /// How long after an acceptance a backward (against the writing direction) caret jump with + /// unchanged text is treated as stale geometry rather than a real move. Child-run hosts + /// publish the inserted VALUE tens of milliseconds before their run frames reflow, and the + /// run-walk throttle can stretch that to ~100ms more; measured stale-then-fresh re-anchor + /// pairs land 8-100ms after the accept. 300ms covers that with margin while keeping the + /// fail-safe: a host whose anchor genuinely needs a backward settle (slide overshoot in a + /// style-less host) still gets it once the window lapses. + static let backwardDriftHoldWindowMilliseconds = 300 + + /// Whether the caret moved in a way worth re-anchoring to: any vertical move past tolerance + /// (line change, scroll), or a horizontal move past tolerance UNLESS it points against the + /// writing direction inside the post-accept hold window. A same-line backward jump with + /// unchanged text right after an accept cannot be a real move: real backward moves change the + /// preceding text and tear the session down through the reconciler instead of reaching this + /// gate. What does reach it is stale geometry: child-run hosts publish the inserted value + /// before their run frames reflow, so the post-publish caret maps into pre-insert frames and + /// lands a word left of the overlay; re-anchoring there and snapping back on the fresh walk + /// was the runs-aligned accept jitter. Forward jumps stay re-anchorable (legitimate settles), + /// and the hold expires so a host that truly needs a backward correction gets it once + /// geometry has had time to catch up. + private static func caretDriftDemandsReAnchor( + currentGeometry: SuggestionOverlayGeometry, + newCaretRect: CGRect, + millisecondsSinceLastAcceptance: Int? + ) -> Bool { + if abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance { + return true + } + let deltaX = newCaretRect.origin.x - currentGeometry.caretRect.origin.x + guard abs(deltaX) > caretDriftTolerance else { + return false + } + let isBackward = currentGeometry.isRightToLeft + ? deltaX > caretDriftTolerance + : deltaX < -caretDriftTolerance + let insideHoldWindow = millisecondsSinceLastAcceptance + .map { $0 <= backwardDriftHoldWindowMilliseconds } ?? false + return !(isBackward && insideHoldWindow) + } + static func shouldRePresent( currentOverlay: OverlayState, newText: String, newCaretRect: CGRect, newInputFrameRect: CGRect?, newFocusChangeSequence: UInt64, - isAwaitingPostInsertionSync: Bool = false + isAwaitingPostInsertionSync: Bool = false, + millisecondsSinceLastAcceptance: Int? = nil ) -> Bool { // Render mode is the third associated value; it is not part of the stability decision, so // we ignore it. A mode change still re-anchors because text or geometry will also differ. @@ -81,15 +122,13 @@ enum SuggestionOverlayStabilityGate { // estimate; treating that gap as caret drift re-presented (and re-estimated) on every // reconcile tick. With text and field unchanged the estimate is pure-function stable, so // only the frame check below can demand a re-anchor for these overlays. - if currentGeometry.caretQuality != .layoutEstimated { - // Hold small caret deltas (post-insertion AX noise and exact-advance residual); re-anchor - // on genuine moves and on accumulated drift past the tolerance. Compared against the held - // (already-advanced) caret, not a per-tick previous value, so slow drift still gets - // corrected. - if abs(currentGeometry.caretRect.origin.x - newCaretRect.origin.x) > caretDriftTolerance - || abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance { - return true - } + if currentGeometry.caretQuality != .layoutEstimated, + caretDriftDemandsReAnchor( + currentGeometry: currentGeometry, + newCaretRect: newCaretRect, + millisecondsSinceLastAcceptance: millisecondsSinceLastAcceptance + ) { + return true } // `observedCharWidth` is intentionally NOT compared here. Drift in that value also affects // `GhostSuggestionLayout.singleLineFits` (and therefore the panel-origin branch), so during diff --git a/CotabbyTests/CaretRunPlacementTests.swift b/CotabbyTests/CaretRunPlacementTests.swift index 182dd962..09c30a4e 100644 --- a/CotabbyTests/CaretRunPlacementTests.swift +++ b/CotabbyTests/CaretRunPlacementTests.swift @@ -169,4 +169,57 @@ final class CaretRunPlacementTests: XCTestCase { XCTAssertEqual(midWanted?.runIndex, 4) XCTAssertEqual(midWanted?.mode, .aligned) } + + // MARK: - Trailing-gap extrapolation (text published before run frames reflow) + + func test_placement_textGrownPastTheLastRunReportsTheTrailingGap() { + // The accept-time staleness signature: the parent value already contains the inserted + // " world" but the cached runs predate it. Parking the caret at the stale trailing edge + // sat a full word left of the truth; the gap count lets the caller extend the estimate by + // measured character widths instead. + let result = placement(runs: ["Hello"], parent: "Hello world", caret: 11) + + XCTAssertEqual( + result, + Placement(runIndex: 0, fraction: 1, mode: .aligned, trailingGapCharacters: 6) + ) + } + + func test_placement_interiorGapNearThePreviousEdgeReportsTheGap() { + // Insert before a later block: the caret sits in the widened separator gap, nearer the + // run it extends; the gap is extrapolable because it stays on the same line. + let result = placement(runs: ["Hello", "later block"], parent: "Hello inserted\nlater block", caret: 8) + + XCTAssertEqual( + result, + Placement(runIndex: 0, fraction: 1, mode: .aligned, trailingGapCharacters: 3) + ) + } + + func test_placement_gapSpanningALineBreakKeepsTheSnap() { + // A newline in the gap means the caret renders on another line entirely; linear + // extrapolation along X would be wrong, so the trailing-edge snap stays. + let result = placement(runs: ["Hello"], parent: "Hello\nworld", caret: 11) + + XCTAssertEqual( + result, + Placement(runIndex: 0, fraction: 1, mode: .aligned, trailingGapCharacters: 0) + ) + } + + func test_placement_hugeTrailingGapRefusesExtrapolation() { + // A reflow-everything edit (large paste) cannot be modeled by a linear extension; fall + // back to the snap and let the fresh walk correct. + let pasted = String(repeating: "a", count: 80) + let result = placement(runs: ["Hello"], parent: "Hello " + pasted, caret: 6 + 80) + + XCTAssertEqual(result?.trailingGapCharacters, 0) + XCTAssertEqual(result?.fraction ?? -1, 1, accuracy: 0.001) + } + + func test_placement_caretInsideARunReportsNoGap() { + let result = placement(runs: ["Hello world"], parent: "Hello world", caret: 5) + + XCTAssertEqual(result?.trailingGapCharacters, 0) + } } diff --git a/CotabbyTests/InsertedTextAdvanceTests.swift b/CotabbyTests/InsertedTextAdvanceTests.swift index 1baa935c..53a59cb7 100644 --- a/CotabbyTests/InsertedTextAdvanceTests.swift +++ b/CotabbyTests/InsertedTextAdvanceTests.swift @@ -79,6 +79,29 @@ final class InsertedTextAdvanceTests: XCTestCase { XCTAssertGreaterThan(withHostFont.origin.x, oldCaret.origin.x) } + func test_width_prefersTheMeasuredRunCharWidthOverTheResolvedFont() throws { + // Child-run derived hosts measure the average character width from the host's own rendered + // run frames; that direct measurement outranks any font-based approximation. + let style = ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil) + let measured = try XCTUnwrap( + InsertedTextAdvance.width(of: " world", observedCharWidth: 7.5, style: style) + ) + XCTAssertEqual(measured, 7.5 * 6, accuracy: 0.001) + + // Without the measurement the resolved font carries the estimate. + let viaFont = try XCTUnwrap( + InsertedTextAdvance.width(of: " world", observedCharWidth: nil, style: style) + ) + XCTAssertEqual( + viaFont, + try XCTUnwrap(InsertedTextAdvance.width(of: " world", style: style)), + accuracy: 0.001 + ) + + XCTAssertNil(InsertedTextAdvance.width(of: " world", observedCharWidth: nil, style: nil)) + XCTAssertNil(InsertedTextAdvance.width(of: "", observedCharWidth: 7.5, style: style)) + } + func test_width_refusesUnusableInputs() { XCTAssertNil(InsertedTextAdvance.width(of: "", style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil))) XCTAssertNil(InsertedTextAdvance.width(of: " world", style: nil)) diff --git a/CotabbyTests/StaticTextRunWalkThrottleTests.swift b/CotabbyTests/StaticTextRunWalkThrottleTests.swift index 6dfba928..db3375f3 100644 --- a/CotabbyTests/StaticTextRunWalkThrottleTests.swift +++ b/CotabbyTests/StaticTextRunWalkThrottleTests.swift @@ -98,4 +98,30 @@ final class StaticTextRunWalkThrottleTests: XCTestCase { XCTAssertTrue(first.isEmpty) XCTAssertTrue(second.isEmpty) } + + func test_invalidate_forcesAFreshWalkInsideTheWindow() { + // After Cotabby's own synthetic insert the cached run texts predate the inserted chunk; + // invalidation makes the next caller walk fresh frames even though neither the field nor + // the window changed. + let throttle = StaticTextRunWalkThrottle() + let start = Date(timeIntervalSinceReferenceDate: 100) + var walkCount = 0 + + _ = throttle.runs(focusChangeSequence: 1, interval: 0.1, now: start) { + walkCount += 1 + return runA + } + throttle.invalidate() + let afterInvalidation = throttle.runs( + focusChangeSequence: 1, + interval: 0.1, + now: start.addingTimeInterval(0.01) + ) { + walkCount += 1 + return runB + } + + XCTAssertEqual(walkCount, 2) + XCTAssertEqual(afterInvalidation.map(\.text), ["beta"]) + } } diff --git a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift index a8a69bae..82f9edd7 100644 --- a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift +++ b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift @@ -374,6 +374,23 @@ final class SuggestionCoordinatorPredictionTests: XCTestCase { XCTAssertEqual(rig.overlayController.advanceInlineCalls.first?.inserted, " world") } + func test_accept_stampsTheAcceptanceAndInvalidatesTransientCaretCaches() { + // Child-run hosts cache their static-run walk; after our own insert those cached runs + // predate the inserted chunk and would map the published caret a word left. The accept + // must invalidate that cache and stamp the acceptance time so the stability gate can + // scope its backward-drift hold. + let rig = retained(makeCoordinatorRig()) + let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1) + _ = rig.interactionState.startSession(fullText: " world again", liveContext: context, latency: 0.05) + rig.overlayController.showSuggestion(" world again", geometry: CotabbyTestFixtures.overlayGeometry()) + + XCTAssertNil(rig.coordinator.lastAcceptanceAt) + XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion()) + + XCTAssertNotNil(rig.coordinator.lastAcceptanceAt) + XCTAssertEqual(rig.focusProvider.transientCaretCacheInvalidations, 1) + } + func test_reconcileDuringPostInsertionSyncWindow_neverReAnchorsTheOverlay() { // The TextEdit accept jitter: Tab inserts " world" and the overlay advances immediately, // but the +30ms refresh can read AX BEFORE the host publishes the insert. That snapshot's diff --git a/CotabbyTests/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/SuggestionCoordinatorTestSupport.swift index 75244afd..a3ff9016 100644 --- a/CotabbyTests/SuggestionCoordinatorTestSupport.swift +++ b/CotabbyTests/SuggestionCoordinatorTestSupport.swift @@ -30,6 +30,7 @@ final class RigPermissionProvider: SuggestionPermissionProviding { final class RigFocusProvider: SuggestionFocusProviding { var snapshot: FocusSnapshot private(set) var refreshCount = 0 + private(set) var transientCaretCacheInvalidations = 0 let snapshotSubject = PassthroughSubject() @@ -44,6 +45,10 @@ final class RigFocusProvider: SuggestionFocusProviding { func refreshNow() { refreshCount += 1 } + + func invalidateTransientCaretCaches() { + transientCaretCacheInvalidations += 1 + } } @MainActor diff --git a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift index 3c3de16f..b318e172 100644 --- a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift +++ b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift @@ -398,4 +398,140 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { ) ) } + + // MARK: - Backward drift after an accept (stale child-run frames) + + func test_backwardDriftShortlyAfterAnAccept_holds() { + // Child-run hosts publish the inserted value before their run frames reflow, so the + // post-publish caret maps into pre-insert frames and lands a word LEFT of the overlay. + // Re-anchoring there and snapping back on the fresh walk was the runs-aligned accept + // jitter; within the post-accept window a same-line backward jump is always staleness. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(caretRect: CGRect(x: 320, y: 210, width: 2, height: 18)), + mode: .inline + ) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 80 + ) + ) + } + + func test_backwardDriftOutsideTheAcceptWindow_reAnchors() { + // The hold is a staleness shield, not a one-way ratchet: once geometry has had time to + // catch up, a backward correction (e.g. settling a slide overshoot in a style-less host) + // must still land. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(caretRect: CGRect(x: 320, y: 210, width: 2, height: 18)), + mode: .inline + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 800 + ) + ) + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: nil + ), + "No recorded acceptance means no staleness shield" + ) + } + + func test_forwardDriftInsideTheAcceptWindow_stillReAnchors() { + // Forward jumps are the legitimate settles (the host published and the caret moved on); + // the directional hold must not block them. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(), + mode: .inline + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect.offsetBy(dx: 40, dy: 0), + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 80 + ) + ) + } + + func test_backwardDriftWithALineChange_reAnchors() { + // A vertical move past tolerance is a real line change (wrap, scroll); direction on X no + // longer marks it as stale. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(caretRect: CGRect(x: 320, y: 210, width: 2, height: 18)), + mode: .inline + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: CGRect(x: 140, y: 240, width: 2, height: 18), + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 80 + ) + ) + } + + func test_backwardDriftForRTL_isMirrored() { + // In RTL the caret advances leftward, so "backward" staleness arrives as a RIGHTWARD jump. + let rtlGeometry = SuggestionOverlayGeometry( + caretRect: Self.caretRect, + inputFrameRect: Self.inputFrame, + caretQuality: .exact, + observedCharWidth: 8, + isRightToLeft: true, + focusChangeSequence: 7 + ) + let current: OverlayState = .visible(text: " again", geometry: rtlGeometry, mode: .inline) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect.offsetBy(dx: 60, dy: 0), + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 80 + ), + "A rightward jump is against the RTL writing direction and must be held in the window" + ) + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect.offsetBy(dx: -60, dy: 0), + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + millisecondsSinceLastAcceptance: 80 + ), + "A leftward jump is the RTL forward direction and stays re-anchorable" + ) + } }