Skip to content

Commit 837fe21

Browse files
authored
Hold TextKit-mirror caret anchors perfectly still through word accepts (#691)
1 parent 86f17f9 commit 837fe21

8 files changed

Lines changed: 147 additions & 11 deletions

Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,21 @@ extension SuggestionCoordinator {
240240
insertionChunk: String,
241241
liveContext: FocusedInputContext
242242
) {
243-
if overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) {
243+
// A layout-estimated anchor lives in the hidden text layout's coordinate system, and the
244+
// estimator models the accepted chunk's true advance (soft wrap included) better than any
245+
// width shift can: that re-anchor, fed with the pending insertion below, is exactly what
246+
// the caret repair was built for on these hosts. Sliding instead leaves the anchor a
247+
// ghost-vs-host font error away from the next estimate and the settle showed up as a
248+
// post-accept jerk. Skip the slide so the estimator places the tail where the
249+
// post-publish estimate will also land; nothing moves afterwards.
250+
let heldOverlayQuality: CaretGeometryQuality?
251+
if case let .visible(_, geometry, _) = overlayState {
252+
heldOverlayQuality = geometry.caretQuality
253+
} else {
254+
heldOverlayQuality = nil
255+
}
256+
if heldOverlayQuality != .layoutEstimated,
257+
overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) {
244258
return
245259
}
246260

Cotabby/Support/BrowserAppDetector.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import Foundation
1515
///
1616
/// Matching is by case-insensitive bundle-identifier prefix to tolerate channel suffixes
1717
/// (`com.google.Chrome.canary`, `com.google.Chrome.beta`, etc.).
18-
enum BrowserAppDetector {
18+
nonisolated enum BrowserAppDetector {
1919
/// Every browser family, used for the broad "typing in a browser" tone hint.
2020
private static let browserBundlePrefixes: [String] = [
2121
"com.apple.safari",

Cotabby/Support/ControlTokenMarkers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import Foundation
2020
/// into a text field expecting a completion, so removing them cannot eat legitimate prose or code.
2121
/// `<think>…</think>` reasoning blocks are intentionally not listed here: they are handled separately
2222
/// so the text around them survives.
23-
enum ControlTokenMarkers {
23+
nonisolated enum ControlTokenMarkers {
2424
/// A Llama-3 role-header block, e.g. `<|start_header_id|>assistant<|end_header_id|>`. The role
2525
/// name sits *between* the markers, so removing the markers individually would leak the role word
2626
/// into the ghost text; this strips the whole block. `|` is escaped because it is a regex

Cotabby/Support/SuggestionOverlayStabilityGate.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,22 @@ enum SuggestionOverlayStabilityGate {
7474
if isAwaitingPostInsertionSync {
7575
return false
7676
}
77-
// Hold small caret deltas (post-insertion AX noise and exact-advance residual); re-anchor on
78-
// genuine moves and on accumulated drift past the tolerance. Compared against the held
79-
// (already-advanced) caret, not a per-tick previous value, so slow drift still gets corrected.
80-
if abs(currentGeometry.caretRect.origin.x - newCaretRect.origin.x) > caretDriftTolerance
81-
|| abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance {
82-
return true
77+
// A layout-estimated anchor was computed by the hidden text layout from (text, field
78+
// frame, style), not from the resolver's caret. Fresh snapshots still carry the RAW
79+
// resolver rect (an AXFrame proportional guess, or a derived rect the repair overrode),
80+
// which lives in a different trust system and routinely sits a word or more away from the
81+
// estimate; treating that gap as caret drift re-presented (and re-estimated) on every
82+
// reconcile tick. With text and field unchanged the estimate is pure-function stable, so
83+
// only the frame check below can demand a re-anchor for these overlays.
84+
if currentGeometry.caretQuality != .layoutEstimated {
85+
// Hold small caret deltas (post-insertion AX noise and exact-advance residual); re-anchor
86+
// on genuine moves and on accumulated drift past the tolerance. Compared against the held
87+
// (already-advanced) caret, not a per-tick previous value, so slow drift still gets
88+
// corrected.
89+
if abs(currentGeometry.caretRect.origin.x - newCaretRect.origin.x) > caretDriftTolerance
90+
|| abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance {
91+
return true
92+
}
8393
}
8494
// `observedCharWidth` is intentionally NOT compared here. Drift in that value also affects
8595
// `GhostSuggestionLayout.singleLineFits` (and therefore the panel-origin branch), so during

Cotabby/Support/TerminalAppDetector.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Foundation
55
/// Terminal apps have their own completion, history, and shell integrations that conflict with
66
/// ghost-text autocomplete. Cotabby stays out of the way automatically so the user doesn't have to
77
/// manually disable each terminal they use.
8-
enum TerminalAppDetector {
8+
nonisolated enum TerminalAppDetector {
99
/// Bundle identifiers of well-known macOS terminal emulators.
1010
private static let terminalBundleIdentifiers: Set<String> = [
1111
"com.apple.Terminal",

CotabbyTests/SuggestionCoordinatorPredictionTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,42 @@ final class SuggestionCoordinatorPredictionTests: XCTestCase {
338338

339339
// MARK: - Post-insertion stillness
340340

341+
func test_accept_layoutEstimatedOverlaySkipsTheSlideAndReAnchorsViaTheEstimator() {
342+
// TextKit-mirror hosts: the overlay anchor came from the hidden layout estimate, so a
343+
// width-based slide leaves it a ghost-vs-host font error away from the next estimate and
344+
// the settle reads as a post-accept jerk. The accept must skip the slide and go through
345+
// the presenting path, where the layout repair (fed the pending insertion) re-anchors at
346+
// exactly the position the post-publish estimate will reproduce.
347+
let rig = retained(makeCoordinatorRig())
348+
let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1)
349+
_ = rig.interactionState.startSession(fullText: " world again", liveContext: context, latency: 0.05)
350+
rig.overlayController.showSuggestion(
351+
" world again",
352+
geometry: CotabbyTestFixtures.overlayGeometry(caretQuality: .layoutEstimated)
353+
)
354+
355+
XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion())
356+
357+
XCTAssertTrue(
358+
rig.overlayController.advanceInlineCalls.isEmpty,
359+
"A layout-estimated overlay must never width-slide on accept"
360+
)
361+
XCTAssertEqual(rig.overlayController.shownTexts.last, " again", "The estimator path re-presented the tail")
362+
}
363+
364+
func test_accept_trustedGeometryStillAttemptsTheSlideFirst() {
365+
let rig = retained(makeCoordinatorRig())
366+
let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1)
367+
_ = rig.interactionState.startSession(fullText: " world again", liveContext: context, latency: 0.05)
368+
rig.overlayController.showSuggestion(" world again", geometry: CotabbyTestFixtures.overlayGeometry())
369+
370+
XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion())
371+
372+
XCTAssertEqual(rig.overlayController.advanceInlineCalls.count, 1)
373+
XCTAssertEqual(rig.overlayController.advanceInlineCalls.first?.remaining, " again")
374+
XCTAssertEqual(rig.overlayController.advanceInlineCalls.first?.inserted, " world")
375+
}
376+
341377
func test_reconcileDuringPostInsertionSyncWindow_neverReAnchorsTheOverlay() {
342378
// The TextEdit accept jitter: Tab inserts " world" and the overlay advances immediately,
343379
// but the +30ms refresh can read AX BEFORE the host publishes the insert. That snapshot's

CotabbyTests/SuggestionCoordinatorTestSupport.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,19 @@ final class RigOverlayController: SuggestionOverlayControlling {
6464
var onStateChange: ((OverlayState) -> Void)?
6565
private(set) var shownTexts: [String] = []
6666
private(set) var hideReasons: [String] = []
67+
/// Records slide attempts (and declines them, like the protocol default) so tests can assert
68+
/// which accept paths even try to slide versus re-anchor through a present.
69+
private(set) var advanceInlineCalls: [(remaining: String, inserted: String)] = []
6770

6871
init(state: OverlayState = .hidden(reason: "initial")) {
6972
self.state = state
7073
}
7174

75+
func advanceInline(to remainingText: String, insertedText: String) -> Bool {
76+
advanceInlineCalls.append((remainingText, insertedText))
77+
return false
78+
}
79+
7280
func showSuggestion(_ text: String, geometry: SuggestionOverlayGeometry) {
7381
shownTexts.append(text)
7482
state = .visible(text: text, geometry: geometry, mode: .inline)

CotabbyTests/SuggestionOverlayStabilityGateTests.swift

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
1818
private static func geometry(
1919
caretRect: CGRect = caretRect,
2020
inputFrameRect: CGRect? = inputFrame,
21+
caretQuality: CaretGeometryQuality = .exact,
2122
focusChangeSequence: UInt64 = 7
2223
) -> SuggestionOverlayGeometry {
2324
SuggestionOverlayGeometry(
2425
caretRect: caretRect,
2526
inputFrameRect: inputFrameRect,
26-
caretQuality: .exact,
27+
caretQuality: caretQuality,
2728
observedCharWidth: 8,
2829
isRightToLeft: false,
2930
focusChangeSequence: focusChangeSequence
@@ -330,4 +331,71 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
330331
)
331332
)
332333
}
334+
335+
// MARK: - Layout-estimated anchors (TextKit mirror hosts)
336+
337+
func test_layoutEstimatedAnchor_ignoresRawCaretDrift() {
338+
// The held anchor came from the hidden text layout; fresh snapshots still carry the RAW
339+
// resolver caret (an AXFrame proportional guess), which routinely sits a word or more
340+
// away. Treating that gap as drift re-presented and re-estimated on every reconcile
341+
// tick, and around accepts it was the jerk-left-then-back: with text and field unchanged
342+
// the estimate cannot move, so the gate must hold.
343+
let current: OverlayState = .visible(
344+
text: " again",
345+
geometry: Self.geometry(
346+
caretRect: CGRect(x: 320, y: 210, width: 2, height: 18),
347+
caretQuality: .layoutEstimated
348+
),
349+
mode: .inline
350+
)
351+
352+
XCTAssertFalse(
353+
SuggestionOverlayStabilityGate.shouldRePresent(
354+
currentOverlay: current,
355+
newText: " again",
356+
newCaretRect: Self.caretRect,
357+
newInputFrameRect: Self.inputFrame,
358+
newFocusChangeSequence: 7
359+
)
360+
)
361+
}
362+
363+
func test_layoutEstimatedAnchor_stillReAnchorsOnFrameTextOrFieldChange() {
364+
// The estimate is a pure function of (text, field frame, style): when one of its real
365+
// inputs changes, or the field itself does, the re-anchor must still happen.
366+
let current: OverlayState = .visible(
367+
text: " again",
368+
geometry: Self.geometry(caretQuality: .layoutEstimated),
369+
mode: .inline
370+
)
371+
372+
XCTAssertTrue(
373+
SuggestionOverlayStabilityGate.shouldRePresent(
374+
currentOverlay: current,
375+
newText: " again",
376+
newCaretRect: Self.caretRect,
377+
newInputFrameRect: Self.inputFrame.offsetBy(dx: 40, dy: 0),
378+
newFocusChangeSequence: 7
379+
),
380+
"A field-frame move re-positions the estimate and must re-anchor"
381+
)
382+
XCTAssertTrue(
383+
SuggestionOverlayStabilityGate.shouldRePresent(
384+
currentOverlay: current,
385+
newText: " different",
386+
newCaretRect: Self.caretRect,
387+
newInputFrameRect: Self.inputFrame,
388+
newFocusChangeSequence: 7
389+
)
390+
)
391+
XCTAssertTrue(
392+
SuggestionOverlayStabilityGate.shouldRePresent(
393+
currentOverlay: current,
394+
newText: " again",
395+
newCaretRect: Self.caretRect,
396+
newInputFrameRect: Self.inputFrame,
397+
newFocusChangeSequence: 8
398+
)
399+
)
400+
}
333401
}

0 commit comments

Comments
 (0)