Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -357,6 +364,8 @@ extension SuggestionCoordinator {
return false
}

lastAcceptanceAt = Date()
focusModel.invalidateTransientCaretCaches()
cancelPredictionWork()
latestGenerationNumber = session.baseContext.generation
clearSuggestion(clearDiagnostics: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ extension SuggestionCoordinator {
return
}

focusModel.invalidateTransientCaretCaches()
cancelPredictionWork()
clearSuggestion(clearDiagnostics: false)
hideOverlay(reason: "Overlay hidden because Cotabby automatically fixed a typo.")
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions Cotabby/Models/FocusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Cotabby/Models/FocusTrackingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions Cotabby/Models/SuggestionSubsystemContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,21 @@ 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 {
/// Conservative default: age unknown, so `refreshIfStale` always refreshes. Production
/// 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
Expand Down
79 changes: 72 additions & 7 deletions Cotabby/Services/Focus/AXTextGeometryResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -396,19 +431,29 @@ 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 {
if position > 0 {
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)
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions Cotabby/Services/Focus/FocusSnapshotResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Cotabby/Services/Focus/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
12 changes: 12 additions & 0 deletions Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions Cotabby/Support/InsertedTextAdvance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down
2 changes: 1 addition & 1 deletion Cotabby/Support/SuggestionAnchorCache.swift
Original file line number Diff line number Diff line change
@@ -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`).
Expand Down
Loading