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
10 changes: 10 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -811,6 +814,7 @@
78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = "<group>"; };
78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = "<group>"; };
7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = "<group>"; };
7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = "<group>"; };
7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = "<group>"; };
7D472F9F396672E57873303B /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = "<group>"; };
7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextColorPreset.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -866,6 +870,7 @@
9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModels.swift; sourceTree = "<group>"; };
9E6439213F2A273702A6E26B /* GhostFontMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostFontMetrics.swift; sourceTree = "<group>"; };
9E72A3972E15749337539C2D /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = "<group>"; };
A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = "<group>"; };
A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = "<group>"; };
A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPaneView.swift; sourceTree = "<group>"; };
A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
29 changes: 21 additions & 8 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ extension SuggestionCoordinator {
insertionChunk: String,
liveContext: FocusedInputContext
) {
if overlayController.advanceInline(to: remainingText) {
if overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) {
return
}

Expand All @@ -250,6 +250,7 @@ extension SuggestionCoordinator {
oldCaretRect: liveContext.caretRect,
caretQuality: liveContext.caretQuality,
observedCharWidth: liveContext.observedCharWidth,
fieldStyle: liveContext.resolvedFieldStyle,
isRightToLeft: isRTL
)
presentOverlay(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -628,20 +633,28 @@ 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)
]
return (insertedChunk as NSString).size(withAttributes: attrs).width
}

/// 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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 8 additions & 7 deletions Cotabby/Models/SuggestionSubsystemContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading