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