From 4df7cae5ec47e3e828e68a6ffd4f98ae8e21e06c Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:38:56 -0700 Subject: [PATCH] fix(overlay): keep ghost text perfectly still through word accepts Accepting a word in TextEdit sometimes jumped the remaining ghost left by the accepted word's width and snapped it back right one poll tick later. Root cause: the +30ms post-insertion refresh can read AX before the host publishes the synthetic insert. The session reconciler tolerates that lag, but presentation discarded the fact and ran the stability gate against the pre-insertion caret; a full word of drift blows past the 6pt tolerance, so the gate re-anchored the overlay onto the just-accepted text, and the post-publish poll bounced it back. Two changes, one per motion source: - The stability gate now holds geometry outright while the interaction state is awaiting the post-insertion AX sync (same field, same text). Stale pre-publish geometry is never worth re-anchoring to, and the hold lifts on the exact tick the publish lands. - The accept-time slide and the predicted-caret fallback now measure the caret's true travel with the field's own resolved font instead of the ghost render font (floored at 14pt) or a fixed system-14 approximation. Helvetica-12 hosts were overshooting 5pt per word and Menlo-11 hosts 11pt, accumulating past the gate tolerance and surfacing as a sideways nudge with no input in flight. With the travel measured in the host's font, the anchor matches the published caret within kerning noise and post-accept reconciles have nothing left to correct. --- Cotabby.xcodeproj/project.pbxproj | 10 ++ .../SuggestionCoordinator+Acceptance.swift | 29 ++++-- .../SuggestionCoordinator+Prediction.swift | 5 +- .../Models/SuggestionSubsystemContracts.swift | 15 +-- Cotabby/Services/UI/OverlayController.swift | 28 ++++-- Cotabby/Support/InsertedTextAdvance.swift | 36 +++++++ .../SuggestionOverlayStabilityGate.swift | 17 +++- CotabbyTests/InsertedTextAdvanceTests.swift | 99 +++++++++++++++++++ ...SuggestionCoordinatorPredictionTests.swift | 51 ++++++++++ .../SuggestionOverlayStabilityGateTests.swift | 77 +++++++++++++++ 10 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 Cotabby/Support/InsertedTextAdvance.swift create mode 100644 CotabbyTests/InsertedTextAdvanceTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 02a03f32..68042b3b 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -305,6 +305,7 @@ 709F365A846B908D953FA92D /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 70D6F9480DA4104AD5669569 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 7179FB0EC6411166CCD79F6B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; + 7324B18578C646B1ADFF0C3F /* InsertedTextAdvanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; @@ -523,6 +524,7 @@ CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */; }; CF39EB76C3ECF8F764C1B4FB /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */; }; CF4205B85D881B8176590D25 /* FocusSnapshotResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */; }; + CFDB42AB7DC7F9663450BC7F /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */; }; D0D4C0E28F5CD99669A49414 /* FoundationModelAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */; }; D1D7D50E5C620042CEA3A77E /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E0AA9F90B83B823132880E6F /* LaunchAtLogin */; }; D21EBD25BCB37E69B633BC00 /* CotabbyDebugOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93AF1246C1C2E296A1162E63 /* CotabbyDebugOptionsTests.swift */; }; @@ -549,6 +551,7 @@ DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */; }; DAD77998F793468D4D64B705 /* DateMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */; }; DB1310FF3576ACA6472C4DB1 /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */; }; + DB7BE0A914BF75654D9EA6FF /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */; }; DC0394C83D334B92A512A775 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; DC84D6A6A2F9A1060CD20ABB /* TokenCountEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA30E71C21C77BB6EA4C166 /* TokenCountEstimator.swift */; }; DCABB8D2B391C7820D6CA5FF /* InsertionSafetyGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */; }; @@ -811,6 +814,7 @@ 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = ""; }; + 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextColorPreset.swift; sourceTree = ""; }; @@ -866,6 +870,7 @@ 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModels.swift; sourceTree = ""; }; 9E6439213F2A273702A6E26B /* GhostFontMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostFontMetrics.swift; sourceTree = ""; }; 9E72A3972E15749337539C2D /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = ""; }; + A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPaneView.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -1366,6 +1371,7 @@ D0AF9479EF020071CA64CCC1 /* HuggingFaceModelsTests.swift */, BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */, 01F583E92B0A78212B330E6E /* InputSuppressionControllerTests.swift */, + 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */, 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */, 2960080A726E51198225147A /* InsertionStrategySelectorTests.swift */, 2930EC34057319130393696B /* KeyCodeLabelsTests.swift */, @@ -1596,6 +1602,7 @@ 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */, 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */, 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */, + A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */, 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */, E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */, EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */, @@ -1932,6 +1939,7 @@ 266DB36F6BD7AD1038F13E39 /* InputModels.swift in Sources */, 5ADAA8600CE1A5ED570F889E /* InputMonitor.swift in Sources */, 3AC2F7F221F7C59242B06DFC /* InputSuppressionController.swift in Sources */, + CFDB42AB7DC7F9663450BC7F /* InsertedTextAdvance.swift in Sources */, FC0C0C43EEC80325FE699B5A /* InsertionSafetyGate.swift in Sources */, ADBCB725707ED11B19C7F08D /* InsertionStrategySelector.swift in Sources */, 1C267B67EA61527B74C9D051 /* KeyCodeLabels.swift in Sources */, @@ -2156,6 +2164,7 @@ 5F019E5A0679FFB52F129798 /* InputModels.swift in Sources */, 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */, 6955C3A4D7AB3EEF7FA7C469 /* InputSuppressionController.swift in Sources */, + DB7BE0A914BF75654D9EA6FF /* InsertedTextAdvance.swift in Sources */, DCABB8D2B391C7820D6CA5FF /* InsertionSafetyGate.swift in Sources */, 9D0F4829D11BCD4DB1290410 /* InsertionStrategySelector.swift in Sources */, F78F594F77C26C233377E71F /* KeyCodeLabels.swift in Sources */, @@ -2342,6 +2351,7 @@ 663D37E35292F38666D807A7 /* HuggingFaceModelsTests.swift in Sources */, 07D046D406411ED85AC5758A /* InputMonitorTests.swift in Sources */, 0FCBF2250722780E46A92EE6 /* InputSuppressionControllerTests.swift in Sources */, + 7324B18578C646B1ADFF0C3F /* InsertedTextAdvanceTests.swift in Sources */, 83EC3543DC45B1601F119BF9 /* InsertionSafetyGateTests.swift in Sources */, FC255241A3B34A5717F09B36 /* InsertionStrategySelectorTests.swift in Sources */, F66F0D982EBAF5A3E99C5342 /* KeyCodeLabelsTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 8a32feb3..f5290b64 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -240,7 +240,7 @@ extension SuggestionCoordinator { insertionChunk: String, liveContext: FocusedInputContext ) { - if overlayController.advanceInline(to: remainingText) { + if overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) { return } @@ -250,6 +250,7 @@ extension SuggestionCoordinator { oldCaretRect: liveContext.caretRect, caretQuality: liveContext.caretQuality, observedCharWidth: liveContext.observedCharWidth, + fieldStyle: liveContext.resolvedFieldStyle, isRightToLeft: isRTL ) presentOverlay( @@ -492,9 +493,9 @@ extension SuggestionCoordinator { } state = .ready(text: advancedSession.remainingText, latency: advancedSession.latency) - // Same exact-width slide as Tab acceptance; the user typed the next characters, so the tail - // shrank by them. Fall back to the (session-start) caret anchor only if the slide can't apply. - if !overlayController.advanceInline(to: advancedSession.remainingText) { + // Same slide as Tab acceptance; the user typed the next characters, so the caret traveled + // by exactly them. Fall back to the (session-start) caret anchor only if the slide can't apply. + if !overlayController.advanceInline(to: advancedSession.remainingText, insertedText: typedCharacters) { presentOverlay( text: advancedSession.remainingText, at: session.baseContext.caretRect, @@ -578,17 +579,21 @@ extension SuggestionCoordinator { /// Estimates the caret rect after inserting a chunk by shifting the old caret in the text /// direction. LTR shifts right; RTL shifts left. /// When `observedCharWidth` is available (measured from real AX child frames), we use it - /// directly — this matches the target app's actual font. Falls back to NSFont measurement. + /// directly — this matches the target app's actual font. Otherwise the field's resolved font + /// measures the chunk (the host's true caret travel), and only a host with no usable style + /// falls back to a system-font approximation. static func predictedCaretRect( after insertedChunk: String, oldCaretRect: CGRect, caretQuality: CaretGeometryQuality, observedCharWidth: CGFloat?, + fieldStyle: ResolvedFieldStyle? = nil, isRightToLeft: Bool = false ) -> CGRect { let measuredWidth = predictedChunkWidth( insertedChunk: insertedChunk, - observedCharWidth: observedCharWidth + observedCharWidth: observedCharWidth, + fieldStyle: fieldStyle ) let chunkWidth: CGFloat @@ -628,12 +633,20 @@ extension SuggestionCoordinator { private static func predictedChunkWidth( insertedChunk: String, - observedCharWidth: CGFloat? + observedCharWidth: CGFloat?, + fieldStyle: ResolvedFieldStyle? = nil ) -> CGFloat { if let observed = observedCharWidth { return observed * CGFloat(insertedChunk.count) } + // The field's own font is the host's true caret travel; the system-14 fallback measured + // TextEdit's Helvetica 12 a quarter too wide, and the resulting overshoot surfaced as a + // corrective snap once AX published the real caret. + if let hostAdvance = InsertedTextAdvance.width(of: insertedChunk, style: fieldStyle) { + return hostAdvance + } + let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 14) ] @@ -641,7 +654,7 @@ extension SuggestionCoordinator { } /// Gives the host app ~30ms to process the synthetic keystroke, then forces an AX snapshot - /// so the overlay snaps to the real caret position without waiting for the 250ms poll. + /// so the overlay snaps to the real caret position without waiting for the 80ms poll. func schedulePostInsertionRefresh() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in guard let self else { return } diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index f051fb24..9a9312c4 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -698,7 +698,10 @@ extension SuggestionCoordinator { newText: reconciledSession.remainingText, newCaretRect: liveContext.caretRect, newInputFrameRect: liveContext.inputFrameRect, - newFocusChangeSequence: liveContext.focusChangeSequence + 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 ) { presentOverlay( text: reconciledSession.remainingText, diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index 7b1b0b45..77ece468 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -226,18 +226,19 @@ protocol SuggestionOverlayControlling: AnyObject { func showSuggestion(_ text: String, geometry: SuggestionOverlayGeometry) func hide(reason: String) - /// Advances a visible single-line inline ghost to `remainingText` by sliding the panel by the - /// exact rendered width of the text just handed off, keeping the remaining glyphs on the same - /// pixels (no caret re-read, no jitter). Returns `false` when the controller cannot safely slide - /// (hidden, mirror mode, RTL, multi-line, or nothing rendered to measure against); callers then - /// fall back to a caret-anchored present. - func advanceInline(to remainingText: String) -> Bool + /// Advances a visible single-line inline ghost to `remainingText` by sliding the panel right + /// by the caret's travel for `insertedText` (the text that just landed in the host, whether + /// Cotabby typed it or the user did). The slide reads the held overlay state, never a fresh AX + /// caret, so it cannot jitter against AX noise. Returns `false` when the controller cannot + /// safely slide (hidden, mirror mode, RTL, multi-line, or nothing rendered to measure + /// against); callers then fall back to a caret-anchored present. + func advanceInline(to remainingText: String, insertedText: String) -> Bool } extension SuggestionOverlayControlling { /// Default: not supported, so conformers that do not render an inline panel (e.g. test doubles) /// transparently fall back to the caret-anchored present path. - func advanceInline(to remainingText: String) -> Bool { false } + func advanceInline(to remainingText: String, insertedText: String) -> Bool { false } } @MainActor diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index d50411f3..cbecf86a 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -225,12 +225,12 @@ final class OverlayController: SuggestionOverlayControlling { } /// Advances a visible single-line inline ghost to `remainingText` by sliding the panel right by - /// the exact rendered width of the text just handed off, so the remaining glyphs stay on the same - /// pixels. This is the "perfectly still" path for word-by-word acceptance and type-through: it - /// reads the held overlay state (not a fresh AX caret), so it cannot jitter against AX noise. + /// the caret's travel for `insertedText`. This is the "perfectly still" path for word-by-word + /// acceptance and type-through: it reads the held overlay state (not a fresh AX caret), so it + /// cannot jitter against AX noise. /// Returns `false` when the held overlay is not a single-line, LTR, inline ghost this can safely /// slide; the caller then falls back to a caret-anchored present. - func advanceInline(to remainingText: String) -> Bool { + func advanceInline(to remainingText: String, insertedText: String) -> Bool { guard case let .visible(beforeText, geometry, mode) = state, case .inline = mode, !geometry.isRightToLeft, @@ -242,8 +242,24 @@ final class OverlayController: SuggestionOverlayControlling { } let renderFont = lastInlineRenderFont ?? NSFont.systemFont(ofSize: fontSize) - let shift = GhostSuggestionLayout.renderedWidth(of: beforeText, font: renderFont) - - GhostSuggestionLayout.renderedWidth(of: remainingText, font: renderFont) + // Trusted-geometry hosts get the slide measured in the field's own font: that is the + // caret's true travel, so the anchor stays aligned with the post-publish AX caret and the + // stability gate never has to issue a delayed corrective nudge. The ghost render font is + // floored at 14pt for legibility, so its width of the same text overshoots a 12pt host by + // ~15% per accepted word; that error used to accumulate in the anchor until the gate + // snapped the tail sideways with no input in flight. The cost is a few points of tail + // shift at the accept keystroke itself (the ghost glyphs are wider than the host's), which + // lands exactly when the text visibly changes anyway. Untrusted/web geometry keeps the + // 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) { + shift = hostAdvance + } else { + shift = GhostSuggestionLayout.renderedWidth(of: beforeText, font: renderFont) + - GhostSuggestionLayout.renderedWidth(of: remainingText, font: renderFont) + } // A non-positive or non-finite shift means the tail did not shrink as expected; re-anchor. guard shift.isFinite, shift > 0 else { return false diff --git a/Cotabby/Support/InsertedTextAdvance.swift b/Cotabby/Support/InsertedTextAdvance.swift new file mode 100644 index 00000000..597510bc --- /dev/null +++ b/Cotabby/Support/InsertedTextAdvance.swift @@ -0,0 +1,36 @@ +import AppKit + +/// File overview: +/// Measures how far a host field's caret travels when text is inserted, using the field's own +/// resolved font. +/// +/// The accept-time overlay slide and the predicted-caret fallback both need the caret's true +/// travel, not the ghost render font's width of the same text. The ghost is deliberately floored +/// at 14pt for legibility while hosts commonly render smaller (TextEdit's Helvetica 12, plain-text +/// Menlo 11), so a ghost-font measurement overshoots the real caret advance by 5-11pt per accepted +/// word. That error lands in the overlay's anchor bookkeeping, accumulates past the stability +/// gate's drift tolerance, and surfaces as a sideways nudge tens of milliseconds after the accept, +/// when no input is happening and any motion reads as jitter. Measuring with the field's own font +/// 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 { + /// 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). + /// + /// Whitespace is measured as-is: a leading boundary space is real caret travel, so this + /// deliberately does not share `GhostSuggestionLayout`'s display normalization. A style whose + /// face name fails to resolve still uses the host's point size with the system face; the size + /// dominates the width error the ghost-font fallback suffers from. + static func width(of text: String, style: ResolvedFieldStyle?) -> CGFloat? { + guard !text.isEmpty, let style, let pointSize = style.fontPointSize, pointSize > 0 else { + return nil + } + let font = style.fontName.flatMap { NSFont(name: $0, size: pointSize) } + ?? NSFont.systemFont(ofSize: pointSize) + let width = (text as NSString).size(withAttributes: [.font: font]).width + guard width.isFinite, width > 0 else { + return nil + } + return width + } +} diff --git a/Cotabby/Support/SuggestionOverlayStabilityGate.swift b/Cotabby/Support/SuggestionOverlayStabilityGate.swift index 1b29d7c9..60aca22f 100644 --- a/Cotabby/Support/SuggestionOverlayStabilityGate.swift +++ b/Cotabby/Support/SuggestionOverlayStabilityGate.swift @@ -41,12 +41,22 @@ enum SuggestionOverlayStabilityGate { /// - The caret moved beyond `caretDriftTolerance` from where the overlay is currently anchored /// (a genuine caret move, or accumulated advance drift that needs correcting). /// - The host editor's frame moved on screen (window drag, sheet appear, etc.). + /// + /// `isAwaitingPostInsertionSync` short-circuits the geometry checks entirely: between a + /// synthesized insert and the host publishing it back through AX, the snapshot's caret is the + /// PRE-insertion one, a full accepted-word-width left of where the overlay correctly sits. The + /// drift tolerance cannot tell that stale rect from a genuine caret move, so the +30ms + /// post-insertion refresh used to re-anchor the ghost onto the just-accepted word and the next + /// 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. static func shouldRePresent( currentOverlay: OverlayState, newText: String, newCaretRect: CGRect, newInputFrameRect: CGRect?, - newFocusChangeSequence: UInt64 + newFocusChangeSequence: UInt64, + isAwaitingPostInsertionSync: Bool = false ) -> 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. @@ -59,6 +69,11 @@ enum SuggestionOverlayStabilityGate { if currentText != newText { return true } + // Same field, same text, host has not published our own insert yet: every geometric field + // of this snapshot describes the pre-insertion state and must not move the overlay. + if isAwaitingPostInsertionSync { + return false + } // 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. diff --git a/CotabbyTests/InsertedTextAdvanceTests.swift b/CotabbyTests/InsertedTextAdvanceTests.swift new file mode 100644 index 00000000..1baa935c --- /dev/null +++ b/CotabbyTests/InsertedTextAdvanceTests.swift @@ -0,0 +1,99 @@ +import AppKit +import XCTest +@testable import Cotabby + +/// Locks the host-font caret-travel measurement that keeps the accept-time overlay slide aligned +/// with where AX will report the caret once the host publishes the insert. The numbers here +/// document the bug being prevented: the ghost render font is floored at 14pt, so measuring the +/// accepted chunk with it overshoots a 12pt host by more than the stability gate's 6pt drift +/// tolerance within one or two accepts, surfacing as a sideways nudge with no input in flight. +final class InsertedTextAdvanceTests: XCTestCase { + func test_width_usesTheFieldsOwnFaceAndSize() throws { + let helvetica12 = ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil) + let width = try XCTUnwrap(InsertedTextAdvance.width(of: " world", style: helvetica12)) + + let expected = (" world" as NSString).size(withAttributes: [ + .font: try XCTUnwrap(NSFont(name: "Helvetica", size: 12)) + ]).width + XCTAssertEqual(width, expected, accuracy: 0.01) + } + + func test_width_atHostSizeIsSmallerThanTheGhostFloorMeasurement() throws { + // The exact regression scenario: TextEdit renders Helvetica 12, the ghost renders the + // same face at the 14pt legibility floor. The per-word delta must be visible to this + // test the same way it was visible on screen. + let host = try XCTUnwrap( + InsertedTextAdvance.width( + of: " world", + style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil) + ) + ) + let ghostFloor = try XCTUnwrap( + InsertedTextAdvance.width( + of: " world", + style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 14, colorHex: nil) + ) + ) + XCTAssertGreaterThan(ghostFloor - host, 3, "The 12pt vs 14pt mismatch is points per word, not noise") + } + + func test_width_countsLeadingWhitespaceAsRealCaretTravel() throws { + let style = ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil) + let bare = try XCTUnwrap(InsertedTextAdvance.width(of: "world", style: style)) + let spaced = try XCTUnwrap(InsertedTextAdvance.width(of: " world", style: style)) + XCTAssertGreaterThan(spaced, bare, "A boundary space moves the caret and must be measured") + } + + func test_width_unknownFaceFallsBackToSystemAtTheHostsSize() throws { + let style = ResolvedFieldStyle(fontName: "NoSuchFaceEver", fontPointSize: 12, colorHex: nil) + let width = try XCTUnwrap(InsertedTextAdvance.width(of: " world", style: style)) + + let expected = (" world" as NSString).size(withAttributes: [ + .font: NSFont.systemFont(ofSize: 12) + ]).width + XCTAssertEqual(width, expected, accuracy: 0.01) + } + + @MainActor + func test_predictedCaretRect_prefersTheHostFontOverTheSystemFallback() { + let oldCaret = CGRect(x: 100, y: 20, width: 2, height: 18) + let withHostFont = SuggestionCoordinator.predictedCaretRect( + after: " world", + oldCaretRect: oldCaret, + caretQuality: .exact, + observedCharWidth: nil, + fieldStyle: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil) + ) + let withSystemFallback = SuggestionCoordinator.predictedCaretRect( + after: " world", + oldCaretRect: oldCaret, + caretQuality: .exact, + observedCharWidth: nil + ) + + XCTAssertLessThan( + withHostFont.origin.x, + withSystemFallback.origin.x, + "Helvetica 12 advances less than system 14; the prediction must track the host" + ) + XCTAssertGreaterThan(withHostFont.origin.x, oldCaret.origin.x) + } + + func test_width_refusesUnusableInputs() { + XCTAssertNil(InsertedTextAdvance.width(of: "", style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 12, colorHex: nil))) + XCTAssertNil(InsertedTextAdvance.width(of: " world", style: nil)) + XCTAssertNil( + InsertedTextAdvance.width( + of: " world", + style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: nil, colorHex: nil) + ), + "A style without a point size cannot describe caret travel" + ) + XCTAssertNil( + InsertedTextAdvance.width( + of: " world", + style: ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 0, colorHex: nil) + ) + ) + } +} diff --git a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift index d3b32b3a..257718f8 100644 --- a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift +++ b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift @@ -336,6 +336,57 @@ final class SuggestionCoordinatorPredictionTests: XCTestCase { XCTAssertFalse(rig.coordinator.overlayState.isVisible) } + // MARK: - Post-insertion stillness + + 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 + // caret is the pre-insertion one, a full word left of the overlay; re-presenting there + // jumped the ghost left, and the post-publish poll snapped it back right. While the + // session is awaiting the publish, reconciles must hold the overlay exactly where it is. + 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()) + + XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion()) + XCTAssertEqual(rig.inserter.insertedChunks, [" world"]) + XCTAssertTrue( + rig.interactionState.isAwaitingPostInsertionSync, + "An accept must arm the publish sentinel before any reconcile can fire" + ) + let presentsAfterAccept = rig.overlayController.shownTexts + + // The +30ms refresh racing the publish: the field still shows the PRE-insertion text and + // caret. The reconciler tolerates the lag; presentation must not re-anchor. + rig.coordinator.reconcileActiveSession(with: rig.focusProvider.snapshot) + + XCTAssertEqual( + rig.overlayController.shownTexts, + presentsAfterAccept, + "A pre-publish reconcile re-presented the overlay at the stale caret" + ) + XCTAssertTrue(rig.interactionState.isAwaitingPostInsertionSync, "The sentinel survives the tolerated tick") + XCTAssertNotNil(rig.interactionState.activeSession) + + // The host publishes: the same reconcile path clears the sentinel and may settle normally. + let publishedSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello world") + rig.focusProvider.snapshot = FocusSnapshot( + applicationName: publishedSnapshot.applicationName, + bundleIdentifier: publishedSnapshot.bundleIdentifier, + capability: .supported, + context: publishedSnapshot, + inspection: nil + ) + rig.coordinator.reconcileActiveSession(with: rig.focusProvider.snapshot) + + XCTAssertFalse( + rig.interactionState.isAwaitingPostInsertionSync, + "The publish must lift the hold so genuine caret moves re-anchor again" + ) + XCTAssertNotNil(rig.interactionState.activeSession, "The session survives the publish settle") + } + // MARK: - Cache reset barrier func test_resetCachedGenerationContext_barrierRunsTheEngineResetExactlyOnce() async { diff --git a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift index bf9c0f3c..5f4c5837 100644 --- a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift +++ b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift @@ -253,4 +253,81 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { ) ) } + + // MARK: - Post-insertion sync window + + func test_awaitingPostInsertionSync_holdsEvenAcrossAWordWidthOfCaretDrift() { + // The +30ms refresh racing the host publish reads the PRE-insertion caret, a full accepted + // word left of where the overlay correctly sits. The drift tolerance cannot tell that from + // a genuine caret move; only the awaiting flag can, and it must win. This is the TextEdit + // left-then-right accept jitter. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(caretRect: CGRect(x: 180, y: 210, width: 2, height: 18)), + mode: .inline + ) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + isAwaitingPostInsertionSync: true + ) + ) + } + + func test_awaitingPostInsertionSync_stillReAnchorsOnFieldOrTextChange() { + // The hold only covers stale geometry for the same field and text: a real field switch or + // a text change mid-window must keep re-anchoring. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(), + mode: .inline + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 8, + isAwaitingPostInsertionSync: true + ) + ) + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " different tail", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + isAwaitingPostInsertionSync: true + ) + ) + } + + func test_syncCleared_wordWidthDriftReAnchorsAgain() { + // Once the host publishes (the sentinel clears), the same drift must re-anchor: that is + // the legitimate settle onto the real caret. + let current: OverlayState = .visible( + text: " again", + geometry: Self.geometry(caretRect: CGRect(x: 180, y: 210, width: 2, height: 18)), + mode: .inline + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: " again", + newCaretRect: Self.caretRect, + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7, + isAwaitingPostInsertionSync: false + ) + ) + } }