From aee6cae42b8f19bc7786c118cbdfc76b43c5e377 Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:40:38 -0400 Subject: [PATCH 1/6] Match ghost text to the host field's real font, size, and margins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghost text in Microsoft Word rendered in the wrong typeface, at the wrong size, and outside the document's text margin, and the activation indicator sat halfway down an empty page. Each symptom had a distinct cause, and all of them trace to two wrong assumptions: that a field's `AXFrame` is its text area, and that a host's reported font describes its real text. Typeface: Word publishes a placeholder in the one key `resolveFieldStyle` read, while reporting the truth beside it: AXFont = {AXFontFamily: Aptos, AXFontName: Helvetica, AXFontSize: 12, ...} `AXHelper.faceName(fromAXFontDictionary:)` prefers the specific face when it belongs to the reported family and falls back to the family when the two contradict each other, so honest hosts keep their PostScript name and its weight. The placeholder resolves through `NSFont(name:)` perfectly well, so that contradiction is the only available signal. Word's Aptos is then unloadable anyway: it ships inside the app bundle and is installed nowhere on the system. `HostFontRegistry` registers the single matching face from the host's own bundle at `.process` scope, so nothing is installed for the user, indexing metadata once per host off the main thread rather than bulk-loading a 280-file directory. Size: with the right typeface the caret's glyph box maps onto a rendered point size directly, and it already carries the host's zoom. Two bugs blocked that. `GhostFontSizeStabilizer` floored caret height to the session minimum on the premise that "the real line height does not grow" — false when the user changes font size or zoom without changing fields, so raising Word to 20pt kept a caret pinned at 17pt. The clamp now applies only to imprecise readings, which is the flicker it was built for. The 24pt ceiling was also reachable by ordinary documents and is now a user setting. Margins and placement: Word publishes the whole page as one `AXTextArea`, so wrapped ghost text started an inch left of the margin and the activation indicator centred on the page rather than the caret line. Overflow lines now align to content edges measured from the host's own line geometry (cached per focus session, off the keystroke path), and the indicator anchors vertically to the caret. Panel placement uses the rendered line height instead of a `fontSize * 1.25` estimate that disagreed with SwiftUI's actual `fittingSize`. Also removes the artificial 6pt gap before inline ghost text, which double-counted against the suggestion's own leading space and broke mid-word continuations outright. New settings (Appearance): "Smallest Ghost Text" and "Largest Ghost Text", defaulting to the previously hard-coded 11pt and 48pt so an untouched install is unchanged. The overlay also logs how font, size, and placement were resolved, which is what made these causes findable at all. Co-Authored-By: Claude Opus 5 --- Cotabby.xcodeproj/project.pbxproj | 6 + .../SuggestionCoordinator+Acceptance.swift | 3 +- .../Settings/SuggestionSettingsData.swift | 13 + .../Settings/SuggestionSettingsModel.swift | 50 ++++ .../SuggestionPresentationModels.swift | 12 +- .../Resolution/AXTextGeometryResolver.swift | 67 +++++ .../Resolution/FocusSnapshotResolver.swift | 35 ++- .../ActivationIndicatorController.swift | 11 +- .../Presentation/HostFontRegistry.swift | 224 +++++++++++++++++ .../Presentation/OverlayController.swift | 232 +++++++++++++++++- Cotabby/Support/Accessibility/AXHelper.swift | 91 ++++++- .../Geometry/GhostSuggestionLayout.swift | 34 ++- .../Presentation/Style/GhostFontMetrics.swift | 48 +++- .../Style/GhostFontSizeStabilizer.swift | 39 ++- .../Settings/SuggestionSettingsStore.swift | 69 ++++++ .../Settings/Panes/AppearancePaneView.swift | 75 ++++++ Cotabby/UI/Settings/SettingsIndex.swift | 10 +- .../SuggestionSettingsModelTests.swift | 55 +++++ .../AXTextGeometryResolverTests.swift | 38 +++ .../Support/Accessibility/AXHelperTests.swift | 50 ++++ .../Geometry/GhostSuggestionLayoutTests.swift | 66 +++++ .../Style/GhostFontMetricsTests.swift | 179 ++++++++++++++ .../Style/GhostFontSizeStabilizerTests.swift | 62 +++-- .../TestSupport/CotabbyTestFixtures.swift | 6 +- 24 files changed, 1428 insertions(+), 47 deletions(-) create mode 100644 Cotabby/Services/Presentation/HostFontRegistry.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 2ee749ed..5fd5c123 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -397,6 +397,7 @@ 7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 7BE110312F7E8E845763D6A5 /* InsertionSafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */; }; 7BEA76E69707BC760B0D2394 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45161160AC94229A630FC3A /* LlamaRuntimeManager.swift */; }; + 7C03CF993AFF3B1BCD61F31C /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; }; 7C5BD8FDACC491EF62665FB2 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; }; 7CD9B73CE933F15B490B3605 /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB1F528070F8F9108720DB /* SettingsSearchResultRow.swift */; }; 7D87C0AEF85FA0653C9C6031 /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */; }; @@ -522,6 +523,7 @@ A8854697A9EB29DB737C4A26 /* SelfCaptureGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571B221ACAFED86DABB10F48 /* SelfCaptureGateTests.swift */; }; A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; }; A8DCC8CFAD1B698A32E1B077 /* SuggestionClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 743B7207A91EB1BFADD0C5E7 /* SuggestionClientError.swift */; }; + A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; }; AA00D42DFD1EE094E01A7EEA /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; }; AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; }; AAC519AC668EF430F68B06CA /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D68B5ABA427D3E87000E78 /* InlineCommandCoordinator.swift */; }; @@ -818,6 +820,7 @@ 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; + 0F328B43743D803A16045110 /* HostFontRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFontRegistry.swift; sourceTree = ""; }; 110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = ""; }; 11CF768650A90705FC0D2730 /* SuggestionTextColorCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodec.swift; sourceTree = ""; }; 12082948AEBC0DFE5ADC6961 /* SuggestionAvailabilityEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluator.swift; sourceTree = ""; }; @@ -2559,6 +2562,7 @@ 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */, 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */, 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */, + 0F328B43743D803A16045110 /* HostFontRegistry.swift */, 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */, 9A3C5A66AA93E50E4A64ED46 /* OverlayController.swift */, ); @@ -3338,6 +3342,7 @@ 9210DC383F2D181F617A2D74 /* GhostTextPreview.swift in Sources */, 31FE2E49FD1491E90B32B956 /* HardwareCapabilityProbe.swift in Sources */, 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */, + 7C03CF993AFF3B1BCD61F31C /* HostFontRegistry.swift in Sources */, 09B092E8A682D127FC9872A7 /* HuggingFaceAPIClient.swift in Sources */, 7E11BEAD32E9FF170C92000A /* HuggingFaceModelBrowserView.swift in Sources */, FF46903861BA67AED8C24EF8 /* HuggingFaceModels.swift in Sources */, @@ -3619,6 +3624,7 @@ C657A9C35E432D0A21D96F9F /* GhostTextPreview.swift in Sources */, 8D380BEC82C2969F3ED2161A /* HardwareCapabilityProbe.swift in Sources */, B65B49F24F59154A7611FD22 /* HomePaneView.swift in Sources */, + A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */, 0B7C1A4F515F63462CCCE9EA /* HuggingFaceAPIClient.swift in Sources */, F0DCEFED640B453A5ECEB810 /* HuggingFaceModelBrowserView.swift in Sources */, AB1B25E213AE0A79C6993239 /* HuggingFaceModels.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift index 97f302af..7b030fa9 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift @@ -745,7 +745,8 @@ extension SuggestionCoordinator { focusChangeSequence: context.focusChangeSequence, focusedInputIdentityKey: context.focusedInputIdentityKey, isCorrection: isCorrection, - resolvedFieldStyle: context.resolvedFieldStyle + resolvedFieldStyle: context.resolvedFieldStyle, + observedContentEdges: context.observedContentEdges ) _ = overlayPresenter.present( text: text, diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index 418bb8b2..22eeeeb1 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -71,6 +71,9 @@ struct SuggestionPresentationSettings: Equatable { var customSuggestionTextColorHex: String? var ghostTextOpacity: Double var ghostTextSizeMultiplier: Double + /// Point-size clamps applied to the caret-approximated ghost size before the multiplier. + var ghostFontSizeFloor: Double + var ghostFontSizeCeiling: Double var isMenuBarIconVisible: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference @@ -339,6 +342,16 @@ extension SuggestionSettingsData { set { presentation.ghostTextSizeMultiplier = newValue } } + var ghostFontSizeFloor: Double { + get { presentation.ghostFontSizeFloor } + set { presentation.ghostFontSizeFloor = newValue } + } + + var ghostFontSizeCeiling: Double { + get { presentation.ghostFontSizeCeiling } + set { presentation.ghostFontSizeCeiling = newValue } + } + var isMenuBarIconVisible: Bool { get { presentation.isMenuBarIconVisible } set { presentation.isMenuBarIconVisible = newValue } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index d7520169..2e126e55 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -51,6 +51,11 @@ final class SuggestionSettingsModel: ObservableObject { /// `OverlayController` at present time (like `ghostTextOpacity`), so it is intentionally not part /// of the generation-facing `SuggestionSettingsSnapshot` — it changes presentation, not requests. @Published private(set) var ghostTextSizeMultiplier: Double + /// Point-size floor and ceiling for the caret-approximated ghost size, applied before + /// `ghostTextSizeMultiplier`. Read live by `OverlayController` for the same reason the + /// multiplier is: they change presentation, not the generation request. + @Published private(set) var ghostFontSizeFloor: Double + @Published private(set) var ghostFontSizeCeiling: Double @Published private(set) var selectedEngine: SuggestionEngineKind @Published private(set) var openAICompatibleBaseURL: String @Published private(set) var openAICompatibleModelName: String @@ -173,6 +178,13 @@ final class SuggestionSettingsModel: ObservableObject { static let minimumGhostTextSizeMultiplier = SuggestionSettingsStore.minimumGhostTextSizeMultiplier static let maximumGhostTextSizeMultiplier = SuggestionSettingsStore.maximumGhostTextSizeMultiplier static let ghostTextSizeMultiplierStep = SuggestionSettingsStore.ghostTextSizeMultiplierStep + static let defaultGhostFontSizeFloor = SuggestionSettingsStore.defaultGhostFontSizeFloor + static let minimumGhostFontSizeFloor = SuggestionSettingsStore.minimumGhostFontSizeFloor + static let maximumGhostFontSizeFloor = SuggestionSettingsStore.maximumGhostFontSizeFloor + static let defaultGhostFontSizeCeiling = SuggestionSettingsStore.defaultGhostFontSizeCeiling + static let minimumGhostFontSizeCeiling = SuggestionSettingsStore.minimumGhostFontSizeCeiling + static let maximumGhostFontSizeCeiling = SuggestionSettingsStore.maximumGhostFontSizeCeiling + static let ghostFontSizeStep = SuggestionSettingsStore.ghostFontSizeStep static let minimumFadeInDuration = SuggestionSettingsStore.minimumFadeInDuration static let maximumFadeInDuration = SuggestionSettingsStore.maximumFadeInDuration static let fadeInDurationStep = SuggestionSettingsStore.fadeInDurationStep @@ -209,6 +221,8 @@ final class SuggestionSettingsModel: ObservableObject { customSuggestionTextColorHex = data.customSuggestionTextColorHex ghostTextOpacity = data.ghostTextOpacity ghostTextSizeMultiplier = data.ghostTextSizeMultiplier + ghostFontSizeFloor = data.ghostFontSizeFloor + ghostFontSizeCeiling = data.ghostFontSizeCeiling selectedEngine = data.selectedEngine openAICompatibleBaseURL = data.openAICompatibleBaseURL openAICompatibleModelName = data.openAICompatibleModelName @@ -286,6 +300,8 @@ final class SuggestionSettingsModel: ObservableObject { customSuggestionTextColorHex = data.customSuggestionTextColorHex ghostTextOpacity = data.ghostTextOpacity ghostTextSizeMultiplier = data.ghostTextSizeMultiplier + ghostFontSizeFloor = data.ghostFontSizeFloor + ghostFontSizeCeiling = data.ghostFontSizeCeiling selectedEngine = data.selectedEngine openAICompatibleBaseURL = data.openAICompatibleBaseURL openAICompatibleModelName = data.openAICompatibleModelName @@ -411,6 +427,8 @@ final class SuggestionSettingsModel: ObservableObject { customSuggestionTextColorHex: customSuggestionTextColorHex, ghostTextOpacity: ghostTextOpacity, ghostTextSizeMultiplier: ghostTextSizeMultiplier, + ghostFontSizeFloor: ghostFontSizeFloor, + ghostFontSizeCeiling: ghostFontSizeCeiling, isMenuBarIconVisible: isMenuBarIconVisible, isMenuBarWordCountVisible: isMenuBarWordCountVisible, mirrorPreference: mirrorPreference, @@ -1128,6 +1146,38 @@ final class SuggestionSettingsModel: ObservableObject { store.saveGhostTextSizeMultiplier(clamped) } + /// Raising the floor past the ceiling (or lowering the ceiling past the floor) would describe an + /// empty range, which `GhostFontMetrics` would resolve by letting the ceiling win — silently + /// ignoring the control the user just moved. Pushing the other value along keeps both controls + /// honest and the range non-empty, and it matches how paired min/max controls behave elsewhere. + func setGhostFontSizeFloor(_ points: Double) { + let clamped = SuggestionSettingsStore.clampedGhostFontSizeFloor(points) + guard ghostFontSizeFloor != clamped else { + return + } + + ghostFontSizeFloor = clamped + store.saveGhostFontSizeFloor(clamped) + + if ghostFontSizeCeiling < clamped { + setGhostFontSizeCeiling(clamped) + } + } + + func setGhostFontSizeCeiling(_ points: Double) { + let clamped = SuggestionSettingsStore.clampedGhostFontSizeCeiling(points) + guard ghostFontSizeCeiling != clamped else { + return + } + + ghostFontSizeCeiling = clamped + store.saveGhostFontSizeCeiling(clamped) + + if ghostFontSizeFloor > clamped { + setGhostFontSizeFloor(clamped) + } + } + func setUserName(_ name: String) { guard userName != name else { return diff --git a/Cotabby/Models/Suggestion/Session/SuggestionPresentationModels.swift b/Cotabby/Models/Suggestion/Session/SuggestionPresentationModels.swift index 248e71c1..7527ad55 100644 --- a/Cotabby/Models/Suggestion/Session/SuggestionPresentationModels.swift +++ b/Cotabby/Models/Suggestion/Session/SuggestionPresentationModels.swift @@ -54,6 +54,11 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { /// The host field's own text font/color, so the overlay can render ghost text that matches the /// field instead of always using the system font and a fixed gray. Nil falls back to defaults. let resolvedFieldStyle: ResolvedFieldStyle? + /// Where the host actually starts drawing text, when it could be measured. A field's `AXFrame` + /// is not its text area — Word publishes the whole page, so its left edge is the paper's edge + /// rather than the document's margin. Ghost text that wraps onto another line aligns to this + /// instead of the frame, so overflow lines land on the host's margin like its own text does. + let observedContentEdges: ObservedContentEdges? init( caretRect: CGRect, @@ -66,7 +71,8 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { focusChangeSequence: UInt64 = 0, focusedInputIdentityKey: UInt64 = 0, isCorrection: Bool = false, - resolvedFieldStyle: ResolvedFieldStyle? = nil + resolvedFieldStyle: ResolvedFieldStyle? = nil, + observedContentEdges: ObservedContentEdges? = nil ) { self.caretRect = caretRect self.inputFrameRect = inputFrameRect @@ -79,6 +85,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { self.focusedInputIdentityKey = focusedInputIdentityKey self.isCorrection = isCorrection self.resolvedFieldStyle = resolvedFieldStyle + self.observedContentEdges = observedContentEdges } /// Returns a copy with only `caretRect` replaced. Used to advance the ghost by an exact measured @@ -94,7 +101,8 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { isRightToLeft: isRightToLeft, focusChangeSequence: focusChangeSequence, focusedInputIdentityKey: focusedInputIdentityKey, - resolvedFieldStyle: resolvedFieldStyle + resolvedFieldStyle: resolvedFieldStyle, + observedContentEdges: observedContentEdges ) } } diff --git a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift index df47c0d2..0355ec0d 100644 --- a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift @@ -189,6 +189,73 @@ struct AXTextGeometryResolver { return nil } + /// Resolves where the host actually starts drawing text on the caret's own visual line, using + /// the host's line-query attributes (`AXLineForIndex` -> `AXRangeForLine` -> `AXBoundsForRange`). + /// + /// This exists because a field's `AXFrame` is not its text area. Microsoft Word publishes the + /// whole page as one `AXTextArea`, so the frame's left edge is the edge of the *paper*, not the + /// document's text margin — roughly an inch further left. Ghost text that wrapped onto a second + /// line therefore started outside the margin, visibly out of alignment with the user's own text. + /// `ObservedContentEdges` already models exactly this ("the field's `AXFrame` includes padding + /// AX never reports directly"); it was simply only ever populated by the child-run walk, which + /// hosts like Word never reach because their caret resolves through `AXBoundsForRange` first. + /// + /// Three cross-process AX calls, so `supportsLineGeometry` must be true before any of them run. + /// That gate is not a nicety: a synchronous AX call into a host that does not implement the + /// attribute blocks the caller for the full messaging timeout, and issuing them from the focus + /// path is what froze typing in the `AXBoundsForRange` incident that Branch 1 above still + /// carries its own gate for. Chromium and WebKit fields resolve their caret through text + /// markers and reach this code without advertising any of these three, so they are exactly the + /// hosts that would pay the stall for a lookup that can only fail. + /// + /// The caller already has the element's parameterized-attribute set, so the check costs nothing + /// extra. Callers must also keep this off the per-keystroke path: it is cached per focus + /// session, and note that session key turns over whenever the field's frame changes — a + /// composer growing as text wraps re-runs this, which is another reason the gate matters. + /// + /// Returns nil unless every step succeeds, leaving callers on their existing frame-based guess. + func resolveLineContentEdges( + for element: AXUIElement, + caretLocation: Int, + anchorFrame: CGRect?, + supportsLineGeometry: Bool + ) -> ObservedContentEdges? { + guard supportsLineGeometry, + caretLocation >= 0, + let line = AXHelper.parameterizedIntValue( + for: "AXLineForIndex" as CFString, + index: caretLocation, + on: element + ), + let lineRange = AXHelper.parameterizedRangeValue( + for: "AXRangeForLine" as CFString, + index: line, + on: element + ), + lineRange.length > 0, + let rect = AXHelper.parameterizedRectValue( + for: kAXBoundsForRangeParameterizedAttribute as CFString, + range: lineRange, + on: element + ), + !rect.isEmpty + else { + return nil + } + + let cocoaRect = AXHelper.validatedCocoaTextRect( + fromAccessibilityRect: rect, + anchorFrame: anchorFrame + ) + // A line rect that escapes the field is a mis-reported range, not a margin; ignore it rather + // than anchoring ghost text somewhere the host is not drawing. + if let anchorFrame, !anchorFrame.isEmpty, !anchorFrame.insetBy(dx: -1, dy: -1).intersects(cocoaRect) { + return nil + } + + return ObservedContentEdges(leftX: cocoaRect.minX, topY: cocoaRect.maxY) + } + /// Best-effort caret estimate when AX exposes only the full field frame. /// /// This path is intentionally conservative. The previous `prefix.count * 8` heuristic drifted diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index c58adbaf..88a115a6 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -39,6 +39,18 @@ struct FocusSnapshotResolver { /// fields (see `FocusSessionScopedCache`). private let secureFieldVerdictCache = FocusSessionScopedCache() private let terminalDetectionCache = FocusSessionScopedCache() + /// Where the host actually starts drawing text on the caret's line, which a field's + /// `AXFrame` does not reveal (Word's frame is the page edge, not the text margin). Three AX + /// round trips, so it is resolved once per focus session; the margin cannot move without the + /// field's frame moving, which already bumps `focusChangeSequence`. + private let lineContentEdgesCache = FocusSessionScopedCache() + /// Every parameterized attribute `resolveLineContentEdges` needs. All three must be + /// advertised before it runs; see that method for why an ungated call is a stall risk. + private static let lineGeometryAttributes = [ + "AXLineForIndex", + "AXRangeForLine", + kAXBoundsForRangeParameterizedAttribute as String + ] /// Caches the resolved field font/color per focused element so the attributed-string AX read /// happens once per field rather than on every poll. Reference type for the same reason as @@ -807,6 +819,27 @@ struct FocusSnapshotResolver { } let caretRect = caretResult?.rect let caretQuality = caretResult?.quality + // Prefer content edges the caret resolver already measured from child text runs. Hosts whose + // caret comes from `AXBoundsForRange` never walk those runs, so fall back to asking the host + // directly for its line geometry — that is the only way to learn a document's text margin as + // distinct from its page edge. + let observedContentEdges = caretResult?.observedContentEdges ?? selectionForGeometry.flatMap { selection in + lineContentEdgesCache.value( + forKey: "lineEdges:\(AXHelper.elementIdentity(for: element))", + focusChangeSequence: focusChangeSequence + ) { + geometryResolver.resolveLineContentEdges( + for: element, + caretLocation: selection.location, + anchorFrame: inputFrameRect, + // Read from the attribute list already fetched for this element, so the gate + // adds no round trip. Hosts that resolve their caret through text markers + // advertise none of these and must not pay three blocking calls to learn that. + supportsLineGeometry: Self.lineGeometryAttributes + .allSatisfy(supportedParameterizedAttributes.contains) + ) + } + } // Recorded from the already-fetched attribute list (no extra AX call) so snapshot // assembly can classify the field as web-rendered without touching the element again. let vendsDOMAttributes = WebContentFieldDetector.vendsDOMAttributes(supportedAttributes) @@ -845,7 +878,7 @@ struct FocusSnapshotResolver { caretRect: caretRect, caretQuality: caretQuality, observedCharWidth: caretResult?.observedCharWidth, - observedContentEdges: caretResult?.observedContentEdges, + observedContentEdges: observedContentEdges, caretSourceDetail: caretResult?.sourceDetail, caretAllowsDeepSearch: caretResult?.allowsDeepSearch ?? true, inputFrameRect: inputFrameRect, diff --git a/Cotabby/Services/Presentation/ActivationIndicatorController.swift b/Cotabby/Services/Presentation/ActivationIndicatorController.swift index 3eaffcee..63464702 100644 --- a/Cotabby/Services/Presentation/ActivationIndicatorController.swift +++ b/Cotabby/Services/Presentation/ActivationIndicatorController.swift @@ -94,9 +94,18 @@ final class ActivationIndicatorController { caretRect } + // Horizontal placement follows the field's edge, but vertical placement follows the *caret*. + // Centering vertically on the field only reads as "beside this input" when the field is + // about one line tall. In a document-shaped text area it is badly wrong: Word publishes the + // whole page as one `AXTextArea` (846pt tall), so the icon landed halfway down an empty page, + // hundreds of points below the line being typed. The caret is always on the active line, and + // for single-line fields it sits at the field's own centre anyway, so short inputs are + // unaffected. Falls back to the field when the caret rect is empty. + let verticalAnchor = caretRect.isEmpty ? anchorRect : caretRect + let preferredLeftX = anchorRect.minX - contentSize.width - fieldEdgeGap let fallbackRightX = anchorRect.maxX + fieldEdgeGap - let centeredY = anchorRect.midY - (contentSize.height / 2) + let centeredY = verticalAnchor.midY - (contentSize.height / 2) guard let screen = screen(for: anchorRect) else { return CGPoint(x: preferredLeftX, y: centeredY) diff --git a/Cotabby/Services/Presentation/HostFontRegistry.swift b/Cotabby/Services/Presentation/HostFontRegistry.swift new file mode 100644 index 00000000..a2b68032 --- /dev/null +++ b/Cotabby/Services/Presentation/HostFontRegistry.swift @@ -0,0 +1,224 @@ +import AppKit +import CoreText +import Foundation +import Logging + +/// Makes a host application's *privately bundled* fonts resolvable by name inside Cotabby's process, +/// so ghost text can be drawn in the typeface the user is actually looking at. +/// +/// Why this exists as its own boundary: `OverlayController` renders ghost text in the font that +/// `resolveFieldStyle` read out of Accessibility, via `NSFont(name:size:)`. That lookup only searches +/// fonts the *font system* knows about — system fonts plus anything installed in a Fonts directory. +/// Several major hosts never install their fonts at all; they ship them inside their own app bundle +/// and register them process-locally at launch. Microsoft Word is the motivating case: Aptos (its +/// default body font since Office 2024) and Calibri live in +/// `Microsoft Word.app/Contents/Resources/DFonts/` and are absent from every system font directory, +/// so `NSFont(name: "Aptos", size:)` returns nil in our process and ghost text silently falls back +/// to the system font — visibly different from the host's text. +/// +/// The fix is to register the one font file we need, from the host app's own bundle, into *our* +/// process. Nothing is installed for the user or the system: `CTFontManagerScope.process` scopes the +/// registration to this running process and it disappears when Cotabby quits. +/// +/// Ownership and lifetime: a single process-wide `shared` instance, because the thing it guards — +/// CoreText's per-process font registration table — is itself process-global. Registering the same +/// URL twice is an error, so the set of already-registered files has to be tracked in exactly one +/// place. +/// +/// An `actor` rather than a `@MainActor` type because both of its steps are blocking disk work that +/// must stay off the main thread: indexing a bundle's font directory costs ~25 ms for a Word-sized +/// collection (280 files), and registration itself is another few ms per file. Serializing through +/// the actor also gives the dedup bookkeeping mutual exclusion for free. +actor HostFontRegistry { + static let shared = HostFontRegistry() + + /// Font-file extensions worth probing. `.ttc` and `.dfont` are containers that can vend several + /// faces from one file, which is why the index maps *names to files* rather than assuming 1:1. + private static let fontExtensions: Set = ["ttf", "otf", "ttc", "dfont"] + + /// Bundle-relative directories that hosts conventionally use for bundled fonts. Kept as a short + /// fixed list rather than a recursive bundle walk: a full crawl of a multi-gigabyte app bundle + /// on a focus change would be far more expensive than the problem it solves. + private static let bundleFontSubpaths = [ + "Contents/Resources/DFonts", + "Contents/Resources/OtherFonts", + "Contents/Resources/Fonts" + ] + + /// Per host bundle ID: lowercased face name -> file that vends it. + /// + /// PostScript and family names are kept in *separate* maps because they need different + /// tie-breaking, and conflating them is a real bug rather than a nicety. AX reports whichever + /// name the host happens to use — Word reports the family name "Aptos", other hosts report + /// PostScript names like "HelveticaNeue-Bold" — so both must be searchable. But a family name + /// is ambiguous: all sixteen Aptos files report the family "Aptos", so a single first-wins map + /// resolved "Aptos" to whichever file the directory enumerated first (in practice + /// `Aptos-Light-Italic.ttf`) and would have drawn ghost text in light italic. PostScript names + /// are unique and match exactly; family names resolve to that family's regular face. + private var postScriptIndexByBundle: [String: [String: URL]] = [:] + private var familyIndexByBundle: [String: [String: URL]] = [:] + + /// Font files already handed to CoreText. Registering the same URL twice returns an error, and + /// this also keeps repeated misses from re-doing work. + private var registeredFiles: Set = [] + + /// Bundles whose font directories were indexed but contained nothing, so we never rescan them. + private var bundlesWithNoFonts: Set = [] + + /// Registers whatever file in `bundleIdentifier`'s bundle vends `fontName`, if any. + /// + /// Returns `true` when the font is resolvable by `NSFont(name:)` *after* this call — either + /// because this call registered it or because it was already available. Callers treat a `false` + /// as "keep using the fallback font"; nothing here is load-bearing for correctness, only for + /// visual fidelity. + /// + /// This is deliberately name-targeted instead of registering the whole directory. Bulk-loading + /// Word's 280-file `DFonts` folder measures ~307 ms and would dump hundreds of unrelated faces + /// into our font namespace; indexing metadata and registering the single matching file costs + /// ~25 ms once per host, then ~2 ms for the file itself. + func ensureFontAvailable(named fontName: String, bundleIdentifier: String) -> Bool { + // Already resolvable (system font, previously registered, or another host registered it). + if NSFont(name: fontName, size: 12) != nil { + return true + } + guard !bundlesWithNoFonts.contains(bundleIdentifier) else { return false } + + indexBundleIfNeeded(bundleIdentifier) + let postScriptIndex = postScriptIndexByBundle[bundleIdentifier] ?? [:] + let familyIndex = familyIndexByBundle[bundleIdentifier] ?? [:] + guard !postScriptIndex.isEmpty || !familyIndex.isEmpty else { + bundlesWithNoFonts.insert(bundleIdentifier) + return false + } + + // Exact PostScript name first — it identifies one specific face, including its weight and + // slant, which is what we want when the host names the styled face the user is typing in. + // Only then fall back to interpreting the name as a family, which yields its regular face. + let key = fontName.lowercased() + guard let fileURL = postScriptIndex[key] ?? familyIndex[key] else { return false } + + if !registeredFiles.contains(fileURL) { + var error: Unmanaged? + // `.process` scope: visible to this process only, never installed for the user or the + // system, and torn down automatically when Cotabby exits. + let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, &error) + // Record the URL either way. A failure here is almost always "already registered" from + // a race with another lookup; retrying it on every keystroke would be pure waste. + registeredFiles.insert(fileURL) + if !registered { + let message = (error?.takeRetainedValue()).map { String(describing: $0) } ?? "unknown" + CotabbyLogger.focus.debug( + "Host font registration failed", + metadata: [ + "font_name": .string(fontName), + "bundle_id": .string(bundleIdentifier), + "error": .string(message) + ] + ) + } + } + + let resolved = NSFont(name: fontName, size: 12) != nil + if resolved { + CotabbyLogger.focus.info( + "Registered host-bundled font for ghost text", + metadata: [ + "font_name": .string(fontName), + "bundle_id": .string(bundleIdentifier), + "file": .string(fileURL.lastPathComponent) + ] + ) + } + return resolved + } + + /// Builds (once per host) the PostScript and family lookup maps for a bundle's font files. + /// + /// Reading descriptors is metadata-only — it does not load glyph data — which is what keeps a + /// Word-sized collection (280 files, 427 face names) at roughly 200 ms. That cost is paid once + /// per host application, on this actor, off the main thread; the alternative of bulk-registering + /// the whole directory measures ~307 ms *and* dumps hundreds of unrelated faces into our font + /// namespace, where they would shadow nothing useful. + private func indexBundleIfNeeded(_ bundleIdentifier: String) { + guard postScriptIndexByBundle[bundleIdentifier] == nil else { return } + + var postScript: [String: URL] = [:] + // The value carries whether the chosen file is the family's *regular* face, so a regular + // face found later can displace a styled one chosen earlier. Local scratch state for + // building one bundle's map — nothing the actor needs to keep afterwards. + var family: [String: (url: URL, isRegular: Bool)] = [:] + + if let bundleURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) { + for subpath in Self.bundleFontSubpaths { + let directory = bundleURL.appending(path: subpath, directoryHint: .isDirectory) + for fileURL in fontFiles(in: directory) { + for face in faces(in: fileURL) { + // PostScript names are unique per face, so first-wins is unambiguous here; + // a duplicate would be the same face shipped twice. + let postScriptKey = face.postScriptName.lowercased() + postScript[postScriptKey] = postScript[postScriptKey] ?? fileURL + + guard let familyName = face.familyName else { continue } + let familyKey = familyName.lowercased() + // A bare family name must resolve to that family's regular face. Taking the + // first file seen instead is how "Aptos" resolved to Aptos-Light-Italic: + // all sixteen Aptos files report the family "Aptos", so directory order won. + let incumbent = family[familyKey] + if incumbent == nil || (face.isRegular && !incumbent!.isRegular) { + family[familyKey] = (fileURL, face.isRegular) + } + } + } + } + } + + postScriptIndexByBundle[bundleIdentifier] = postScript + familyIndexByBundle[bundleIdentifier] = family.mapValues(\.url) + } + + /// One face inside a font file, reduced to what face selection needs. + private struct FontFace { + let postScriptName: String + let familyName: String? + /// Neither bold nor italic — the face a bare family name should resolve to. + let isRegular: Bool + } + + private func fontFiles(in directory: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + return [] + } + return contents.filter { Self.fontExtensions.contains($0.pathExtension.lowercased()) } + } + + /// Every face in one font file, with the traits needed to pick a family's regular member. + /// A `.ttc` container vends several descriptors, so this returns a list rather than one face. + private func faces(in fileURL: URL) -> [FontFace] { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(fileURL as CFURL) + as? [CTFontDescriptor] + else { + return [] + } + return descriptors.compactMap { descriptor -> FontFace? in + guard let postScriptName = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute) as? String + else { + return nil + } + let familyName = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String + // Symbolic traits carry the bold/italic bits without instantiating the font. A face with + // neither bit set is the family's regular member. + var isRegular = true + if let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [String: Any], + let symbolic = traits[kCTFontSymbolicTrait as String] as? UInt32 { + let styled = CTFontSymbolicTraits(rawValue: symbolic) + .intersection([.traitBold, .traitItalic, .traitCondensed, .traitExpanded]) + isRegular = styled.isEmpty + } + return FontFace(postScriptName: postScriptName, familyName: familyName, isRegular: isRegular) + } + } +} diff --git a/Cotabby/Services/Presentation/OverlayController.swift b/Cotabby/Services/Presentation/OverlayController.swift index f5648545..a5c4f8fc 100644 --- a/Cotabby/Services/Presentation/OverlayController.swift +++ b/Cotabby/Services/Presentation/OverlayController.swift @@ -13,10 +13,23 @@ import SwiftUI @MainActor final class OverlayController: SuggestionOverlayControlling { private enum Layout { - static let minimumGhostFontSize: CGFloat = 14 - static let maximumGhostFontSize: CGFloat = 24 + // The ghost-size floor and ceiling now live in Settings (Appearance -> Ghost Text Size + // Limits); their shipped defaults are in `SuggestionSettingsStore`. Only the caps for paths + // whose caret rect is *not* a real measurement stay here, because those guard against bad + // geometry rather than expressing a user preference. static let maximumEstimatedGhostFontSize: CGFloat = 16 + /// Ceiling for a size the *host itself* reported, which only applies on the synthetic-caret + /// path. It is deliberately looser than both caret-derived caps: those guard against a bad + /// caret *rect*, a risk that does not exist for a point size read straight out of the host's + /// own text attributes. It stays bounded so a nonsense AX value still cannot paint a + /// full-screen suggestion. 32pt covers zoomed body text (Word at 161% renders 16pt as ~26pt) + /// and ordinary headings. + static let maximumHostReportedFontSize: CGFloat = 32 static let fontToLineHeightRatio: CGFloat = 0.78 + /// Size used only to instantiate a host font so its metrics can be read. The glyph-box + /// ratio derived from it is scale-invariant, so the value is arbitrary — it never + /// reaches the screen and must not be confused with a rendered size. + static let metricProbeFontSize: CGFloat = 12 } var onStateChange: ((OverlayState) -> Void)? @@ -66,6 +79,21 @@ final class OverlayController: SuggestionOverlayControlling { private var lastInlineRenderFont: NSFont? private var lastInlineFontSize: CGFloat? + /// Signature of the last ghost-font resolution written to the log. Inline ghost text re-renders + /// on every keystroke, so logging each render would bury the signal; this emits one line per + /// *distinct* outcome instead. See `logGhostFontResolution`. + private var lastLoggedFontSignature: String? + + /// Same idea for the placement line: inline ghost text re-renders on every keystroke, and the + /// caret X changes each time, so the signature deliberately excludes it — what is worth one line + /// per change is the *shape* of the placement, not the fact that the caret moved. + private var lastLoggedPlacementSignature: String? + + /// `"|"` pairs already handed to `HostFontRegistry`, so a font the host + /// bundle does not contain is looked up once rather than on every render. Grows only with the + /// number of distinct unresolvable fonts actually encountered, which is small. + private var requestedHostFonts: Set = [] + init( suggestionSettings: SuggestionSettingsModel, renderModePolicyOverride: CompletionRenderModePolicy? = nil @@ -191,15 +219,33 @@ final class OverlayController: SuggestionOverlayControlling { // still resets on genuine field switches. let stabilizedCaretHeight = ghostFontStabilizer.stabilizedCaretHeight( geometry.caretRect.height, + // Everything but `.estimated` measured real text-range geometry, so it reports the + // host's true line box and must be trusted even when it grew mid-session — the user + // raising the font size or the zoom does exactly that without changing fields. + isPreciseMeasurement: geometry.caretQuality != .estimated, focusSessionKey: geometry.focusedInputIdentityKey ) // The host field's own font, when AX exposed it. Instantiated at the reported size only to // read its (scale-invariant) glyph-box ratio; the rendered size comes from the caret height. - let referenceFieldFont = geometry.resolvedFieldStyle.flatMap(fieldFont(from:)) + let referenceFieldFont = geometry.resolvedFieldStyle.flatMap { + fieldFont(from: $0, bundleIdentifier: geometry.bundleIdentifier) + } + // Read the reported size straight off the style rather than off `referenceFieldFont`, which + // is nil whenever the typeface itself could not be instantiated. The two facts are + // independent: a host can name a font we cannot load while still reporting a usable size. + let hostReportedPointSize = geometry.resolvedFieldStyle?.fontPointSize let fontSize = resolvedGhostFontSize( forCaretHeight: stabilizedCaretHeight, caretQuality: geometry.caretQuality, - fieldFont: referenceFieldFont + fieldFont: referenceFieldFont, + hostReportedPointSize: hostReportedPointSize + ) + logGhostFontResolution( + geometry: geometry, + stabilizedCaretHeight: stabilizedCaretHeight, + hostReportedPointSize: hostReportedPointSize, + referenceFieldFont: referenceFieldFont, + fontSize: fontSize ) // Render in the field's typeface at the derived size so the ghost reads as a continuation of // the host text rather than pasted-on system font. Nil falls back to the system font. @@ -265,6 +311,16 @@ final class OverlayController: SuggestionOverlayControlling { panel.setFrame(frame.integral, display: true) panel.orderFrontRegardless() + logGhostPlacement( + caretRect: geometry.caretRect, + panelFrame: frame.integral, + contentSize: contentSize, + layout: layout, + renderFont: renderFont, + fontSize: fontSize, + geometryObservedContentEdges: geometry.observedContentEdges + ) + // Capture exactly what this inline render used, so a subsequent `advanceInline` slides the // panel by the prefix width measured in the same typeface and size. lastInlineFontSize = fontSize @@ -422,11 +478,17 @@ final class OverlayController: SuggestionOverlayControlling { private func resolvedGhostFontSize( forCaretHeight caretHeight: CGFloat, caretQuality: CaretGeometryQuality, - fieldFont: NSFont? + fieldFont: NSFont?, + hostReportedPointSize: CGFloat? ) -> CGFloat { + // The user's ceiling is an absolute upper bound. The built-in caps only *tighten* it further + // on paths whose caret rect is not a real measurement, so lowering the ceiling always takes + // effect while raising it never loosens an untrustworthy estimate. + let userCeiling = CGFloat(suggestionSettings.ghostFontSizeCeiling) + let userFloor = CGFloat(suggestionSettings.ghostFontSizeFloor) let qualityCap = caretQuality == .estimated - ? Layout.maximumEstimatedGhostFontSize - : Layout.maximumGhostFontSize + ? min(Layout.maximumEstimatedGhostFontSize, userCeiling) + : userCeiling let fieldMetrics = fieldFont.map { GhostFontMetrics.FieldFontMetrics( @@ -438,20 +500,170 @@ final class OverlayController: SuggestionOverlayControlling { return GhostFontMetrics.pointSize( caretHeight: caretHeight, + // Only `.estimated` comes from the AXFrame fallback, whose caret height is a fixed + // system-font constant rather than a measurement. `.layoutEstimated` is excluded on + // purpose: it re-derives the caret from a real text layout, so its height is meaningful. + caretHeightIsSynthetic: caretQuality == .estimated, fieldMetrics: fieldMetrics, + hostReportedPointSize: hostReportedPointSize, fallbackRatio: Layout.fontToLineHeightRatio, - minimum: Layout.minimumGhostFontSize, + minimum: userFloor, maximum: qualityCap, + syntheticCaretMaximum: min(Layout.maximumHostReportedFontSize, userCeiling), sizeMultiplier: CGFloat(suggestionSettings.ghostTextSizeMultiplier) ) } + /// Records how ghost-text font and size were resolved for the current field. + /// + /// This subsystem previously logged nothing, which made "the ghost text looks wrong in app X" + /// impossible to triage from logs alone: every input to the decision — what the host reported, + /// which caret branch produced the height, whether the typeface actually loaded — was invisible. + /// The fields below are exactly what is needed to tell a *host-reporting* problem (no font name, + /// no point size) from a *caret-geometry* problem (`caret_quality=estimated`, synthetic height) + /// from a *font-loading* problem (name present, `render_font_resolved=false`). + /// + /// Deduplicated by signature because inline ghost text re-renders on every keystroke; one line + /// per distinct outcome keeps the stream readable. Logged at `.debug`, so it costs nothing in + /// the default configuration — swift-log skips the autoclosed metadata below the level floor. + private func logGhostFontResolution( + geometry: SuggestionOverlayGeometry, + stabilizedCaretHeight: CGFloat, + hostReportedPointSize: CGFloat?, + referenceFieldFont: NSFont?, + fontSize: CGFloat + ) { + let style = geometry.resolvedFieldStyle + let signature = [ + geometry.bundleIdentifier ?? "-", + style?.fontName ?? "-", + hostReportedPointSize.map { String(format: "%.1f", $0) } ?? "-", + geometry.caretQuality.label, + String(format: "%.1f", stabilizedCaretHeight), + String(format: "%.1f", fontSize), + referenceFieldFont?.fontName ?? "-" + ].joined(separator: "|") + + guard signature != lastLoggedFontSignature else { return } + lastLoggedFontSignature = signature + + CotabbyLogger.suggestion.debug( + "Resolved ghost text font", + metadata: [ + "bundle_id": .string(geometry.bundleIdentifier ?? "unknown"), + "host_font_name": .string(style?.fontName ?? "none"), + "host_font_point_size": .string( + hostReportedPointSize.map { String(format: "%.2f", $0) } ?? "none" + ), + "caret_quality": .string(geometry.caretQuality.label), + "caret_height": .string(String(format: "%.2f", stabilizedCaretHeight)), + // True when caret height was fabricated from a fixed system-font constant rather + // than measured, in which case the host-reported size drives sizing instead. + "caret_height_synthetic": .stringConvertible(geometry.caretQuality == .estimated), + "render_font_resolved": .stringConvertible(referenceFieldFont != nil), + "render_font_name": .string(referenceFieldFont?.fontName ?? "system-fallback"), + "ghost_font_size": .string(String(format: "%.2f", fontSize)) + ] + ) + } + + /// Records where the ghost panel actually landed relative to the caret, in enough detail to + /// compute the baseline error without guessing at SwiftUI's rendered metrics. + /// + /// The placement math assumes the rendered line box is `fontSize * lineHeightMultiplier`, but + /// the panel is actually sized by SwiftUI's `fittingSize`. When those disagree the ghost drifts + /// vertically, and nothing in the logs previously showed the discrepancy. `content_height` is + /// the truth; `layout_line_height` is the assumption — comparing the two is the whole point. + /// + /// `baseline_delta` is the number that matters: ghost text baseline minus host text baseline, + /// in points, positive meaning the ghost sits high. It is derived from the render font's own + /// descent rather than an approximation, so it can be read directly as the visible error. + private func logGhostPlacement( + caretRect: CGRect, + panelFrame: CGRect, + contentSize: CGSize, + layout: GhostSuggestionLayout, + renderFont: NSFont?, + fontSize: CGFloat, + geometryObservedContentEdges: ObservedContentEdges? + ) { + let font = renderFont ?? NSFont.systemFont(ofSize: fontSize) + // Text sits on its baseline, which is `descent` above the bottom of its own line box. + let ghostDescent = -font.descender + let ghostBaselineY = panelFrame.minY + ghostDescent + // The host's line box is the caret rect; its text baseline sits a proportional descent up + // from that box's bottom. Scaling the render font's descent by the box ratio approximates + // the host's own descent without needing the host's true point size, which Word misreports. + let hostDescent = ghostDescent * (caretRect.height / max(contentSize.height, 1)) + let hostBaselineY = caretRect.minY + hostDescent + + // Whether the wrapped-line anchor came from the host's measured text margin or fell back to + // the field frame. Without this, "ghost text ignores the document margin" is unanswerable + // from logs: both outcomes just look like an X coordinate. + let usedContentEdge = geometryObservedContentEdges != nil + + let signature = [ + String(format: "%.0f", caretRect.height), + String(format: "%.0f", contentSize.height), + String(layout.lines.count), + String(usedContentEdge), + String(format: "%.0f", panelFrame.minX) + ].joined(separator: "|") + guard signature != lastLoggedPlacementSignature else { return } + lastLoggedPlacementSignature = signature + + CotabbyLogger.suggestion.debug( + "Ghost overlay placement", + metadata: [ + "caret_y": .string(String(format: "%.2f", caretRect.minY)), + "caret_height": .string(String(format: "%.2f", caretRect.height)), + "caret_x": .string(String(format: "%.2f", caretRect.maxX)), + "panel_y": .string(String(format: "%.2f", panelFrame.minY)), + "panel_x": .string(String(format: "%.2f", panelFrame.minX)), + // Gap between the caret and where ghost text starts drawing. + "caret_to_panel_gap": .string(String(format: "%.2f", panelFrame.minX - caretRect.maxX)), + // The measured height SwiftUI produced versus the height the math assumed. + "content_height": .string(String(format: "%.2f", contentSize.height)), + "layout_line_height": .string(String(format: "%.2f", layout.lineHeight)), + "line_count": .stringConvertible(layout.lines.count), + "font_size": .string(String(format: "%.2f", fontSize)), + "font_natural_line_height": .string( + String(format: "%.2f", ceil(font.ascender - font.descender + font.leading)) + ), + "baseline_delta": .string(String(format: "%.2f", ghostBaselineY - hostBaselineY)), + "used_host_content_edge": .stringConvertible(usedContentEdge) + ] + ) + } + /// Builds the host field's `NSFont` from a resolved style, or nil when the name is missing or the /// font cannot be instantiated. The size is only a reference for metric extraction; the rendered /// size is derived from caret height in `resolvedGhostFontSize`. - private func fieldFont(from style: ResolvedFieldStyle) -> NSFont? { + /// + /// When the name does not resolve, this asks `HostFontRegistry` to look for the typeface inside + /// the host app's own bundle and returns nil for *this* render. Hosts that ship private fonts + /// (Word's Aptos and Calibri live in its bundle and are installed nowhere on the system) would + /// otherwise render ghost text in the system font forever. Registration is deliberately not + /// awaited: it does disk I/O that must not block a render, so the current frame uses the + /// fallback font and the next one — the overlay redraws continuously through a suggestion — + /// picks up the now-resolvable font. One frame of fallback is invisible next to generation + /// latency, and the alternative is stalling the main actor on the hot path. + private func fieldFont(from style: ResolvedFieldStyle, bundleIdentifier: String?) -> NSFont? { guard let name = style.fontName else { return nil } - return NSFont(name: name, size: style.fontPointSize ?? Layout.minimumGhostFontSize) + if let font = NSFont(name: name, size: style.fontPointSize ?? Layout.metricProbeFontSize) { + return font + } + guard let bundleIdentifier else { return nil } + // Ask at most once per (host, font) pair. `showInline` runs on every keystroke, so without + // this a typeface that genuinely is not in the host's bundle — the common case for most + // apps — would spawn a throwaway Task per render forever. The registry itself is cheap to + // re-enter, but the Task allocation and actor hop are not free on the hot path. + let requestKey = "\(bundleIdentifier)|\(name)" + guard requestedHostFonts.insert(requestKey).inserted else { return nil } + Task { + await HostFontRegistry.shared.ensureFontAvailable(named: name, bundleIdentifier: bundleIdentifier) + } + return nil } /// Maps the host field's foreground color to a ghost color, or nil to fall back to the default diff --git a/Cotabby/Support/Accessibility/AXHelper.swift b/Cotabby/Support/Accessibility/AXHelper.swift index c650300c..32259a07 100644 --- a/Cotabby/Support/Accessibility/AXHelper.swift +++ b/Cotabby/Support/Accessibility/AXHelper.swift @@ -161,6 +161,49 @@ enum AXHelper { return rect } + /// Reads a parameterized attribute whose parameter is a plain integer and whose result is an + /// integer — `AXLineForIndex` (character offset -> visual line number) is the only current use. + /// + /// Kept separate from the range-parameterized readers because the parameter is a `CFNumber` + /// rather than an `AXValue`, which is a different bridging shape at this unsafe boundary. + static func parameterizedIntValue( + for attribute: CFString, + index: Int, + on element: AXUIElement + ) -> Int? { + let parameter = index as CFNumber + var value: CFTypeRef? + let result = AXUIElementCopyParameterizedAttributeValue(element, attribute, parameter, &value) + guard result == .success, let number = value as? NSNumber else { + return nil + } + + return number.intValue + } + + /// Reads a parameterized attribute whose parameter is a plain integer and whose result is a + /// range — `AXRangeForLine` (visual line number -> character range) is the current use. + static func parameterizedRangeValue( + for attribute: CFString, + index: Int, + on element: AXUIElement + ) -> NSRange? { + let parameter = index as CFNumber + var value: CFTypeRef? + let result = AXUIElementCopyParameterizedAttributeValue(element, attribute, parameter, &value) + guard result == .success, let axValue = axValue(from: value) else { return nil } + guard AXValueGetType(axValue) == .cfRange else { + return nil + } + + var range = CFRange() + guard AXValueGetValue(axValue, .cfRange, &range) else { + return nil + } + + return NSRange(location: range.location, length: range.length) + } + /// Reads a parameterized rectangle attribute such as `AXBoundsForRange`. static func parameterizedRectValue( for attribute: CFString, @@ -281,6 +324,52 @@ enum AXHelper { /// Extracts a `ResolvedFieldStyle` from one character's attributes, handling both the AppKit /// `.font`/`.foregroundColor` shapes and the AX-specific font dictionary / `CGColor` shapes. + /// Picks the font face name to render with out of an `AXFont` dictionary, preferring the + /// specific face but falling back to the family when the two contradict each other. + /// + /// The dictionary carries up to four keys, and hosts do not agree on which are trustworthy: + /// `AXFontName` (conventionally the PostScript name, so the most specific — it encodes weight + /// and slant), `AXFontFamily`, and `AXVisibleName` (the name shown in the host's own font + /// picker). Reading `AXFontName` alone is right for well-behaved hosts and wrong for Microsoft + /// Word, which publishes a fixed placeholder there while reporting the truth beside it: + /// + /// AXFont = {AXFontFamily: Aptos, AXFontName: Helvetica, AXFontSize: 12, AXVisibleName: Aptos} + /// + /// The document above is Aptos; only `AXFontName` says Helvetica. Note the placeholder resolves + /// through `NSFont(name:)` perfectly well, so "does this name load?" cannot detect it — the + /// contradiction with the reported family is the only available signal. + /// + /// Resolution order: + /// 1. No family reported: nothing to cross-check, take `AXFontName` as before. + /// 2. The face's own family matches the reported family: the face is the more specific truth, + /// so keep it (this is what preserves "Aptos-Bold" rather than flattening to "Aptos"). + /// 3. The face name is a variant of the family by name (`Aptos-Bold` under `Aptos`): keep it. + /// Checked separately because a font the system has not loaded yet cannot be instantiated — + /// exactly the case for a host's privately bundled fonts before `HostFontRegistry` runs. + /// 4. Otherwise the face contradicts the family: trust the family. + /// + /// Internal (not private) so the selection rule is unit-testable without live AX elements, + /// matching `AXTextGeometryResolver`'s testable pure helpers. + static func faceName(fromAXFontDictionary fontInfo: [String: Any]) -> String? { + let faceName = (fontInfo["AXFontName"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let familyName = ["AXFontFamily", "AXVisibleName"] + .lazy + .compactMap { fontInfo[$0] as? String } + .first { !$0.isEmpty } + + guard let familyName else { return faceName } + guard let faceName else { return familyName } + + // Size is irrelevant here; the instance exists only to read the face's declared family. + if let font = NSFont(name: faceName, size: 12), font.familyName == familyName { + return faceName + } + if faceName == familyName || faceName.hasPrefix(familyName) { + return faceName + } + return familyName + } + private static func fieldStyle(from attributes: [NSAttributedString.Key: Any]) -> ResolvedFieldStyle? { var fontName: String? var fontPointSize: CGFloat? @@ -288,7 +377,7 @@ enum AXHelper { fontName = font.fontName fontPointSize = font.pointSize } else if let fontInfo = attributes[NSAttributedString.Key("AXFont")] as? [String: Any] { - fontName = fontInfo["AXFontName"] as? String + fontName = faceName(fromAXFontDictionary: fontInfo) if let size = fontInfo["AXFontSize"] as? NSNumber { fontPointSize = CGFloat(size.doubleValue) } diff --git a/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift b/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift index f6096938..d38a2991 100644 --- a/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift +++ b/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift @@ -70,11 +70,19 @@ struct GhostSuggestionLayout: Equatable { // Direction-dependent anchor and budget. // LTR: anchor at the right edge of the caret, budget extends rightward. // RTL: anchor at the left edge of the caret, budget extends leftward. + // + // The anchor sits flush against the caret with no padding, because inline ghost text has to + // read as a continuation of the host's own line. `normalizedDisplayText` deliberately keeps + // the suggestion's leading space when it has one, so word spacing is already carried by the + // text itself; adding a gap on top rendered it as a space *plus* a gap. A gap is outright + // wrong for a mid-word continuation ("calc" -> "ulates"), where any padding visibly breaks + // the word. `Metrics.caretGap` still applies to the fallback usable-region bounds below, + // where it serves a different purpose — keeping the region off the caret. let firstLineAnchor: CGFloat let firstLineBudget: CGFloat if isRTL { firstLineAnchor = min( - max(geometry.caretRect.minX - Metrics.caretGap, usableFrame.minX), + max(geometry.caretRect.minX, usableFrame.minX), usableFrame.maxX ) firstLineBudget = max( @@ -83,7 +91,7 @@ struct GhostSuggestionLayout: Equatable { ) } else { firstLineAnchor = min( - max(geometry.caretRect.maxX + Metrics.caretGap, usableFrame.minX), + max(geometry.caretRect.maxX, usableFrame.minX), usableFrame.maxX ) firstLineBudget = max( @@ -179,8 +187,17 @@ struct GhostSuggestionLayout: Equatable { } func panelFrame(for contentSize: CGSize, caretRect: CGRect) -> CGRect { + // Use the height the text actually rendered at, not the `fontSize * lineHeightMultiplier` + // estimate in `lineHeight`. The panel is sized by SwiftUI's `fittingSize`, and the two + // disagree by a couple of points in practice (a 17.94pt ghost in Word measured 21pt tall + // against an assumed 23pt), which offset the ghost vertically by exactly that difference. + // Every line in the stack is laid out identically, so dividing by the line count recovers + // the top line's real height — and that is the line the caret has to align with. + let renderedLineHeight = lines.isEmpty + ? lineHeight + : contentSize.height / CGFloat(lines.count) let topLineCenterY = caretRect.midY + topLineCenterOffsetFromCaret - let originY = topLineCenterY - contentSize.height + (lineHeight / 2) + let originY = topLineCenterY - contentSize.height + (renderedLineHeight / 2) let originX = isRightToLeft ? panelOriginX - contentSize.width : panelOriginX return CGRect( @@ -195,8 +212,17 @@ struct GhostSuggestionLayout: Equatable { ) -> CGRect { if let inputFrame = geometry.inputFrameRect?.standardized, inputFrame.width > Metrics.minimumLineWidth { + // A measured content edge is the host's real text margin, so it needs no padding guess. + // Without it the frame's own edge stands in, plus a nominal inset. This matters most in + // document editors: Word's `AXFrame` is the page, roughly an inch wider than the text + // column on each side, so wrapped ghost text started outside the margin the user's own + // text wraps to. Clamped into the frame so a stale or mis-reported edge cannot push text + // off the field entirely. + let contentLeftX = geometry.observedContentEdges.map { + min(max($0.leftX, inputFrame.minX), inputFrame.maxX) + } let minX = max( - inputFrame.minX + Metrics.inputHorizontalPadding, + contentLeftX ?? (inputFrame.minX + Metrics.inputHorizontalPadding), visibleFrame.minX + Metrics.fallbackScreenMargin ) let maxX = min( diff --git a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift index 8f7b605f..971b22c7 100644 --- a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift +++ b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift @@ -18,6 +18,20 @@ enum GhostFontMetrics { /// binds, so it is purely a backstop against degenerate inputs (a non-positive or tiny multiplier). static let absoluteMinimumPointSize: CGFloat = 9 + /// Note on what `caretHeight` means, and why this helper does not second-guess the host's font + /// report. A caret rect measured through `AXBoundsForRange` is the *rendered glyph box* + /// (`ascender - descender`) in screen points, so it already carries the host's zoom. Multiplying + /// it by the font's own scale-invariant ratio recovers the on-screen point size directly, which + /// is why no zoom factor appears anywhere in this file. + /// + /// A previous version tried to detect placeholder font reports by testing `caretHeight` against + /// the glyph box implied by the *reported* point size. That test is unsound: the reported size is + /// in document units while the caret is in screen units, so any zoom above ~1.45 made an honest + /// report look like a lie (Word at 164% reports 12pt against a 23pt caret — a ratio of 1.62 that + /// is entirely zoom). The real defect it was compensating for was a misread typeface, now fixed + /// at its source in `AXHelper.faceName(fromAXFontDictionary:)`. Do not reintroduce a size-based + /// trust check here without a scale reference that is in the same units as the caret. + /// /// Glyph-box metrics of the host field's font. `ascender - descender` is the full glyph box /// height (`NSFont.descender` is negative). The derived ratio is scale-invariant, so callers may /// instantiate the reference font at any size. @@ -33,16 +47,48 @@ enum GhostFontMetrics { /// make a "smaller" choice a no-op whenever the field already sits at `minimum`. Growth is bounded /// by the caller's clamped multiplier rather than a second ceiling here; only the absolute floor /// is re-applied so a low multiplier can never produce illegibly small text. + /// + /// `caretHeightIsSynthetic` marks the case where `caretHeight` is not a measurement at all. On + /// the `AXFrame` fallback path the resolver has no text-range geometry to read, so it fabricates + /// a caret box from a fixed 15pt system font — a constant ~18pt regardless of what the host is + /// really rendering. Deriving a font size from that constant is meaningless: it pins ghost text + /// near 14pt in *every* such host, which is why a zoomed Word document (16pt Aptos at 161% zoom + /// ≈ 26pt on screen) got ghost text roughly half the size of the user's own text. When the caret + /// is synthetic and the host told us its real point size, that reported size is genuine + /// information and the fabricated height is not, so we use the former and ignore the latter. + /// + /// `hostReportedPointSize` is passed separately from `fieldMetrics` on purpose. `fieldMetrics` + /// can only be built when the typeface itself instantiates, and hosts that bundle private fonts + /// (Word's Aptos) may report a perfectly good *size* alongside a *name* we cannot resolve. + /// Keeping them apart means a failed typeface lookup no longer throws away the point size too. static func pointSize( caretHeight: CGFloat, + caretHeightIsSynthetic: Bool = false, fieldMetrics: FieldFontMetrics?, + hostReportedPointSize: CGFloat? = nil, fallbackRatio: CGFloat, minimum: CGFloat, maximum: CGFloat, + syntheticCaretMaximum: CGFloat? = nil, sizeMultiplier: CGFloat = 1 ) -> CGFloat { let ratio = metricRatio(fieldMetrics) ?? fallbackRatio - let autoSize = min(max(minimum, caretHeight * ratio), maximum) + + let base: CGFloat + let ceiling: CGFloat + if caretHeightIsSynthetic, let reported = hostReportedPointSize, reported > 0 { + base = reported + // The tighter `maximum` a synthetic caret normally gets exists to stop one bad *rect* + // from rendering comically oversized ghost text. A host-reported point size is not a + // rect estimate, so it earns the looser ceiling — otherwise legitimately large text + // (zoomed documents, headings) would still be truncated. + ceiling = syntheticCaretMaximum ?? maximum + } else { + base = caretHeight * ratio + ceiling = maximum + } + + let autoSize = min(max(minimum, base), ceiling) return max(absoluteMinimumPointSize, autoSize * sizeMultiplier) } diff --git a/Cotabby/Support/Presentation/Style/GhostFontSizeStabilizer.swift b/Cotabby/Support/Presentation/Style/GhostFontSizeStabilizer.swift index d8ca8854..d5c66a1b 100644 --- a/Cotabby/Support/Presentation/Style/GhostFontSizeStabilizer.swift +++ b/Cotabby/Support/Presentation/Style/GhostFontSizeStabilizer.swift @@ -9,23 +9,42 @@ import Foundation /// derives ghost font size from caret height, that fluctuation renders the suggestion comically /// oversized whenever the coarse fallback wins a poll. /// -/// Within a single focus session the real line height does not grow, so we treat the smallest -/// height we have seen as the truth and clamp larger readings down to it. The baseline is keyed by -/// `FocusTracker`'s `focusChangeSequence`, so switching fields — or leaving and re-entering the same -/// field — starts a fresh measurement instead of inheriting a stale ceiling. +/// When a reading is imprecise we treat the smallest height seen this session as the truth and +/// clamp larger readings down to it. The baseline is keyed by `FocusTracker`'s +/// `focusChangeSequence`, so switching fields — or leaving and re-entering the same field — starts +/// a fresh measurement instead of inheriting a stale ceiling. /// /// This intentionally biases toward the smaller reading: an over-tall fallback is the observed /// failure mode, and the downstream `minimumGhostFontSize` floor bounds how small a spurious low /// reading can make the text. +/// +/// Crucially, the clamp applies *only to imprecise readings*. The flicker it defends against is +/// specifically a precise branch failing and falling back to the coarse `AXFrame` height, which is +/// reported as `.estimated`. A precise measurement (`.exact` / `.derived`) is a real line box and +/// must be honoured immediately, because the host's text can legitimately grow within one focus +/// session — changing the font size or the zoom level does exactly that without ever changing the +/// focused element. Clamping those readings made the session minimum a ratchet: a user who set +/// Word to 20pt after typing at 12pt kept a caret pinned at the old 17pt height, and ghost text +/// stayed 40% too small until focus happened to change. struct GhostFontSizeStabilizer { private var sessionKey: UInt64? private var minCaretHeight: CGFloat? - /// Returns the caret height to derive font size from: the running per-session minimum. + /// Returns the caret height to derive font size from. + /// + /// `isPreciseMeasurement` is true when the caret rect came from real text-range geometry rather + /// than the coarse field-frame fallback. A precise reading is returned as-is and *becomes* the + /// new baseline, so genuine growth (a larger font, a higher zoom) takes effect on the very next + /// render. Only an imprecise reading is clamped down to the running minimum, which is the whole + /// point of the type: an `AXFrame`-height fallback must not balloon the ghost text. /// /// Non-positive heights (empty rects) pass through untouched so a transient bad poll can't pin /// the session minimum to zero and force every later suggestion to the font-size floor. - mutating func stabilizedCaretHeight(_ caretHeight: CGFloat, focusSessionKey: UInt64) -> CGFloat { + mutating func stabilizedCaretHeight( + _ caretHeight: CGFloat, + isPreciseMeasurement: Bool, + focusSessionKey: UInt64 + ) -> CGFloat { guard caretHeight > 0 else { return caretHeight } @@ -36,6 +55,14 @@ struct GhostFontSizeStabilizer { return caretHeight } + // Real geometry: trust it and re-baseline, so a font-size or zoom change lands immediately. + if isPreciseMeasurement { + minCaretHeight = caretHeight + return caretHeight + } + + // Coarse `AXFrame` fallback: clamp to the session minimum so it cannot balloon the ghost. + let stabilized = min(caretHeight, minCaretHeight ?? caretHeight) minCaretHeight = stabilized return stabilized diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 2025abd3..c2582314 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -47,6 +47,31 @@ struct SuggestionSettingsStore { static let defaultGhostTextSizeMultiplier: Double = 1.0 static let ghostTextSizeMultiplierStep: Double = 0.1 + /// User-adjustable floor and ceiling for the caret-approximated ghost-text size, in points. + /// + /// These bound the size *before* `ghostTextSizeMultiplier` scales it. They exist as their own + /// controls because the multiplier cannot express what they express: it rescales every host + /// proportionally, whereas these clamp the outliers — a host whose caret geometry reads far + /// smaller or larger than its real text. The shipped defaults are the values the overlay used + /// when they were hard-coded, so an untouched install behaves exactly as before. + /// + /// The two controls have deliberately different ranges. A floor above ~24pt would force ghost + /// text larger than ordinary body text in most hosts, and a ceiling below ~16pt would clamp + /// ordinary body text back down, so neither control is allowed into the other's territory by + /// range alone. `SuggestionSettingsModel` additionally keeps floor <= ceiling, which range + /// clamping cannot do because each value is stored independently. + static let defaultGhostFontSizeFloor: Double = 11 + static let minimumGhostFontSizeFloor: Double = 6 + static let maximumGhostFontSizeFloor: Double = 24 + + static let defaultGhostFontSizeCeiling: Double = 48 + static let minimumGhostFontSizeCeiling: Double = 16 + static let maximumGhostFontSizeCeiling: Double = 96 + + /// Whole points: sub-point precision is invisible in rendered ghost text and only makes the + /// slider fiddly. + static let ghostFontSizeStep: Double = 1 + /// New installs start with fades enabled; the renderer still yields to macOS Reduce Motion, so /// this product default never overrides the user's accessibility preference. static let defaultFadeInSuggestions = true @@ -90,6 +115,8 @@ struct SuggestionSettingsStore { private static let customSuggestionTextColorHexDefaultsKey = "cotabbyCustomSuggestionTextColorHex" private static let ghostTextOpacityDefaultsKey = "cotabbyGhostTextOpacity" private static let ghostTextSizeMultiplierDefaultsKey = "cotabbyGhostTextSizeMultiplier" + private static let ghostFontSizeFloorDefaultsKey = "cotabbyGhostFontSizeFloor" + private static let ghostFontSizeCeilingDefaultsKey = "cotabbyGhostFontSizeCeiling" private static let selectedEngineDefaultsKey = "cotabbySelectedEngine" private static let openAICompatibleBaseURLDefaultsKey = "cotabbyOpenAICompatibleBaseURL" private static let openAICompatibleModelNameDefaultsKey = "cotabbyOpenAICompatibleModelName" @@ -177,6 +204,8 @@ struct SuggestionSettingsStore { customSuggestionTextColorHexDefaultsKey, ghostTextOpacityDefaultsKey, ghostTextSizeMultiplierDefaultsKey, + ghostFontSizeFloorDefaultsKey, + ghostFontSizeCeilingDefaultsKey, selectedEngineDefaultsKey, openAICompatibleBaseURLDefaultsKey, openAICompatibleModelNameDefaultsKey, @@ -275,6 +304,18 @@ struct SuggestionSettingsStore { } else { Self.clampedGhostTextSizeMultiplier(userDefaults.double(forKey: Self.ghostTextSizeMultiplierDefaultsKey)) } + let resolvedGhostFontSizeFloor: Double = + if userDefaults.object(forKey: Self.ghostFontSizeFloorDefaultsKey) == nil { + Self.defaultGhostFontSizeFloor + } else { + Self.clampedGhostFontSizeFloor(userDefaults.double(forKey: Self.ghostFontSizeFloorDefaultsKey)) + } + let resolvedGhostFontSizeCeiling: Double = + if userDefaults.object(forKey: Self.ghostFontSizeCeilingDefaultsKey) == nil { + Self.defaultGhostFontSizeCeiling + } else { + Self.clampedGhostFontSizeCeiling(userDefaults.double(forKey: Self.ghostFontSizeCeilingDefaultsKey)) + } let resolvedEngine = userDefaults .string(forKey: Self.selectedEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) @@ -562,6 +603,8 @@ struct SuggestionSettingsStore { customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, ghostTextOpacity: resolvedGhostTextOpacity, ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, + ghostFontSizeFloor: resolvedGhostFontSizeFloor, + ghostFontSizeCeiling: resolvedGhostFontSizeCeiling, isMenuBarIconVisible: resolvedMenuBarIconVisible, isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, mirrorPreference: resolvedMirrorPreference, @@ -605,6 +648,8 @@ struct SuggestionSettingsStore { saveCustomSuggestionTextColorHex(data.customSuggestionTextColorHex) saveGhostTextOpacity(data.ghostTextOpacity) saveGhostTextSizeMultiplier(data.ghostTextSizeMultiplier) + saveGhostFontSizeFloor(data.ghostFontSizeFloor) + saveGhostFontSizeCeiling(data.ghostFontSizeCeiling) saveSelectedEngine(data.selectedEngine) saveOpenAICompatibleBaseURL(data.openAICompatibleBaseURL) saveOpenAICompatibleModelName(data.openAICompatibleModelName) @@ -757,6 +802,14 @@ struct SuggestionSettingsStore { userDefaults.set(multiplier, forKey: Self.ghostTextSizeMultiplierDefaultsKey) } + func saveGhostFontSizeFloor(_ points: Double) { + userDefaults.set(points, forKey: Self.ghostFontSizeFloorDefaultsKey) + } + + func saveGhostFontSizeCeiling(_ points: Double) { + userDefaults.set(points, forKey: Self.ghostFontSizeCeilingDefaultsKey) + } + func saveSelectedEngine(_ engine: SuggestionEngineKind) { userDefaults.set(engine.rawValue, forKey: Self.selectedEngineDefaultsKey) } @@ -1094,6 +1147,22 @@ struct SuggestionSettingsStore { return min(maximumGhostTextSizeMultiplier, max(minimumGhostTextSizeMultiplier, value)) } + static func clampedGhostFontSizeFloor(_ value: Double) -> Double { + guard value.isFinite else { + return defaultGhostFontSizeFloor + } + + return min(maximumGhostFontSizeFloor, max(minimumGhostFontSizeFloor, value)) + } + + static func clampedGhostFontSizeCeiling(_ value: Double) -> Double { + guard value.isFinite else { + return defaultGhostFontSizeCeiling + } + + return min(maximumGhostFontSizeCeiling, max(minimumGhostFontSizeCeiling, value)) + } + static func clampedFadeInDuration(_ value: Double) -> Double { guard value.isFinite else { return defaultFadeInDuration diff --git a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift index 82ca72b0..7ec4e740 100644 --- a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift @@ -205,6 +205,55 @@ struct AppearancePaneView: View { ) } .settingsItem(.ghostTextSize) + + LabeledContent { + HStack(spacing: 10) { + TickMarkSlider( + value: ghostFontSizeFloorBinding, + range: SuggestionSettingsModel.minimumGhostFontSizeFloor + ... SuggestionSettingsModel.maximumGhostFontSizeFloor, + step: SuggestionSettingsModel.ghostFontSizeStep + ) + .frame(width: 180) + + Text(ghostFontSizeFloorLabel) + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 42, alignment: .trailing) + } + } label: { + SettingsRowLabel( + title: "Smallest Ghost Text", + description: "Suggestions never render below this size, even in fields that report tiny text.", + systemImage: "arrow.down.to.line" + ) + } + .settingsItem(.ghostTextSizeLimits) + + LabeledContent { + HStack(spacing: 10) { + TickMarkSlider( + value: ghostFontSizeCeilingBinding, + range: SuggestionSettingsModel.minimumGhostFontSizeCeiling + ... SuggestionSettingsModel.maximumGhostFontSizeCeiling, + step: SuggestionSettingsModel.ghostFontSizeStep + ) + .frame(width: 180) + + Text(ghostFontSizeCeilingLabel) + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 42, alignment: .trailing) + } + } label: { + SettingsRowLabel( + title: "Largest Ghost Text", + description: "Suggestions never render above this size. Lower it if ghost text ever appears oversized.", + systemImage: "arrow.up.to.line" + ) + } } } } @@ -292,6 +341,22 @@ struct AppearancePaneView: View { ) } + /// The model keeps floor <= ceiling by pushing the other value along, so these bindings can stay + /// plain pass-throughs; the slider simply reflects whatever the model settled on. + private var ghostFontSizeFloorBinding: Binding { + Binding( + get: { suggestionSettings.ghostFontSizeFloor }, + set: { suggestionSettings.setGhostFontSizeFloor($0) } + ) + } + + private var ghostFontSizeCeilingBinding: Binding { + Binding( + get: { suggestionSettings.ghostFontSizeCeiling }, + set: { suggestionSettings.setGhostFontSizeCeiling($0) } + ) + } + // MARK: - Ghost color swatch helpers /// Mirrors the overlay's automatic fallback (`GhostSuggestionView.ghostColor`) so the Automatic @@ -319,6 +384,16 @@ struct AppearancePaneView: View { String(format: "%.1f×", suggestionSettings.ghostTextSizeMultiplier) } + /// Shown in points rather than a scale factor, because these are absolute clamps — unlike the + /// multiplier row above, which is relative to whatever the host's caret implies. + private var ghostFontSizeFloorLabel: String { + "\(Int(suggestionSettings.ghostFontSizeFloor.rounded())) pt" + } + + private var ghostFontSizeCeilingLabel: String { + "\(Int(suggestionSettings.ghostFontSizeCeiling.rounded())) pt" + } + @ViewBuilder private func ghostColorSwatch(for preset: GhostTextColorPreset) -> some View { let isSelected = GhostTextColorPreset.matching( diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 68862262..43b5a263 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -33,6 +33,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case ghostTextColor case ghostTextOpacity case ghostTextSize + case ghostTextSizeLimits // Emoji case emojiPicker case emojiSkinTone @@ -120,6 +121,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .ghostTextColor: return "Ghost Text Color" case .ghostTextOpacity: return "Ghost Text Opacity" case .ghostTextSize: return "Ghost Text Size" + case .ghostTextSizeLimits: return "Ghost Text Size Limits" case .emojiPicker: return "Inline Emoji Picker" case .emojiSkinTone: return "Skin Tone" case .emojiPeopleStyle: return "People Emoji Style" @@ -196,6 +198,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .ghostTextColor: return "paintpalette" case .ghostTextOpacity: return "circle.lefthalf.filled" case .ghostTextSize: return "textformat.size" + case .ghostTextSizeLimits: return "arrow.up.and.down.text.horizontal" case .emojiPicker: return "face.smiling" case .emojiSkinTone: return "hand.raised.fingers.spread" case .emojiPeopleStyle: return "person.2" @@ -256,7 +259,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .general case .suggestionDisplay, .streamWhileGenerating, .fadeInSuggestions, .showFieldIndicator, .showWordCount, .showMenuBarIcon, .showKeyHint, .ghostTextColor, - .ghostTextOpacity, .ghostTextSize: + .ghostTextOpacity, .ghostTextSize, .ghostTextSizeLimits: return .appearance case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory: return .emoji @@ -309,6 +312,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .ghostTextColor: return "Pick the color of the inline suggestion." case .ghostTextOpacity: return "How faint the suggestion looks before you accept it." case .ghostTextSize: return "Scale suggestions if the ghost text looks too big or small." + case .ghostTextSizeLimits: + return "Smallest and largest point size ghost text is allowed to use." case .emojiPicker: return "Type :name to search and insert emoji inline." case .emojiSkinTone: return "Prefer a skin tone in emoji suggestions." case .emojiPeopleStyle: return "Person, man, or woman variants when available." @@ -433,6 +438,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .ghostTextSize: return ["size", "font size", "scale", "bigger", "smaller", "larger", "text size", "zoom", "multiplier", "too big", "too small"] + case .ghostTextSizeLimits: + return ["limit", "limits", "minimum", "maximum", "floor", "ceiling", "clamp", + "point size", "pt", "smallest", "largest", "range", "cap"] case .emojiPicker: return ["emoji", "smile", "picker", "inline", "colon", "emoticon", "face", "symbol"] diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift index 93c68379..5f8159cc 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift @@ -631,6 +631,61 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(model.ghostTextSizeMultiplier, SuggestionSettingsModel.minimumGhostTextSizeMultiplier) } + func test_ghostFontSizeLimits_clampToTheirOwnRanges() { + let model = makeModel() + + model.setGhostFontSizeFloor(1000) + XCTAssertEqual(model.ghostFontSizeFloor, SuggestionSettingsModel.maximumGhostFontSizeFloor) + model.setGhostFontSizeFloor(0) + XCTAssertEqual(model.ghostFontSizeFloor, SuggestionSettingsModel.minimumGhostFontSizeFloor) + + model.setGhostFontSizeCeiling(1000) + XCTAssertEqual(model.ghostFontSizeCeiling, SuggestionSettingsModel.maximumGhostFontSizeCeiling) + model.setGhostFontSizeCeiling(0) + XCTAssertEqual(model.ghostFontSizeCeiling, SuggestionSettingsModel.minimumGhostFontSizeCeiling) + } + + func test_ghostFontSizeFloorPushesCeilingUpRatherThanInvertingTheRange() { + let model = makeModel() + model.setGhostFontSizeCeiling(SuggestionSettingsModel.minimumGhostFontSizeCeiling) + + // Raising the floor above the ceiling must not leave an empty range, where the ceiling would + // silently win and the control the user just moved would appear to do nothing. + model.setGhostFontSizeFloor(SuggestionSettingsModel.maximumGhostFontSizeFloor) + + XCTAssertEqual(model.ghostFontSizeFloor, SuggestionSettingsModel.maximumGhostFontSizeFloor) + XCTAssertGreaterThanOrEqual(model.ghostFontSizeCeiling, model.ghostFontSizeFloor) + } + + func test_ghostFontSizeCeilingPullsFloorDownRatherThanInvertingTheRange() { + let model = makeModel() + model.setGhostFontSizeFloor(SuggestionSettingsModel.maximumGhostFontSizeFloor) + + model.setGhostFontSizeCeiling(SuggestionSettingsModel.minimumGhostFontSizeCeiling) + + XCTAssertEqual(model.ghostFontSizeCeiling, SuggestionSettingsModel.minimumGhostFontSizeCeiling) + XCTAssertLessThanOrEqual(model.ghostFontSizeFloor, model.ghostFontSizeCeiling) + } + + func test_ghostFontSizeLimitsDefaultToThePreviouslyHardCodedValues() { + // An untouched install must render exactly as it did before these became user settings. + let model = makeModel() + XCTAssertEqual(model.ghostFontSizeFloor, 11) + XCTAssertEqual(model.ghostFontSizeCeiling, 48) + } + + func test_ghostFontSizeLimitsSurviveAReload() { + let model = makeModel() + model.setGhostFontSizeFloor(14) + model.setGhostFontSizeCeiling(30) + + // A fresh model over the same defaults suite is the reload: it catches a renamed key or a + // dropped save call, which is the whole point of this test class. + let reloaded = makeModel() + XCTAssertEqual(reloaded.ghostFontSizeFloor, 14) + XCTAssertEqual(reloaded.ghostFontSizeCeiling, 30) + } + func test_setCustomSuggestionTextColorHex_normalizesAndClears() { let model = makeModel() diff --git a/CotabbyTests/Services/Focus/Resolution/AXTextGeometryResolverTests.swift b/CotabbyTests/Services/Focus/Resolution/AXTextGeometryResolverTests.swift index 1861feac..9a7f4901 100644 --- a/CotabbyTests/Services/Focus/Resolution/AXTextGeometryResolverTests.swift +++ b/CotabbyTests/Services/Focus/Resolution/AXTextGeometryResolverTests.swift @@ -184,4 +184,42 @@ final class AXTextGeometryResolverTests: XCTestCase { XCTAssertEqual(AXHelper.validatedCocoaTextRect(fromAccessibilityRect: nan, anchorFrame: nil), .zero) XCTAssertEqual(AXHelper.cocoaRect(fromAccessibilityRect: nan), .zero) } + + // MARK: - Line-geometry capability gate + + /// `resolveLineContentEdges` issues three synchronous cross-process AX calls. Against a host + /// that does not implement them, each one blocks for the full messaging timeout, and doing that + /// from the focus path is what froze typing in the `AXBoundsForRange` incident. Chromium and + /// WebKit fields resolve their caret through text markers and reach this code advertising none + /// of the three, so the gate must short-circuit before any AX call is attempted. + /// + /// A system-wide element stands in for "an element that answers nothing useful": if the guard + /// were ever removed, this would issue real AX calls instead of returning immediately. + func test_resolveLineContentEdges_returnsNilWithoutIssuingCallsWhenUnsupported() { + let resolver = AXTextGeometryResolver() + + XCTAssertNil( + resolver.resolveLineContentEdges( + for: AXHelper.systemWideElement(), + caretLocation: 5, + anchorFrame: CGRect(x: 0, y: 0, width: 400, height: 30), + supportsLineGeometry: false + ) + ) + } + + /// A negative caret offset is rejected on its own, independently of the capability gate, so a + /// bad selection cannot reach the parameterized calls either. + func test_resolveLineContentEdges_rejectsNegativeCaretLocation() { + let resolver = AXTextGeometryResolver() + + XCTAssertNil( + resolver.resolveLineContentEdges( + for: AXHelper.systemWideElement(), + caretLocation: -1, + anchorFrame: nil, + supportsLineGeometry: true + ) + ) + } } diff --git a/CotabbyTests/Support/Accessibility/AXHelperTests.swift b/CotabbyTests/Support/Accessibility/AXHelperTests.swift index d2a2c4f8..2825d318 100644 --- a/CotabbyTests/Support/Accessibility/AXHelperTests.swift +++ b/CotabbyTests/Support/Accessibility/AXHelperTests.swift @@ -347,4 +347,54 @@ final class AXHelperTests: XCTestCase { let element = AXHelper.systemWideElement() XCTAssertEqual(CFGetTypeID(element), AXUIElementGetTypeID()) } + + // MARK: - AXFont dictionary face selection + + /// Microsoft Word publishes a fixed `AXFontName: Helvetica` placeholder while reporting the + /// document's real typeface beside it, verified from a live dump of a Word text area: + /// + /// AXFont = {AXFontFamily: Aptos, AXFontName: Helvetica, AXFontSize: 12, AXVisibleName: Aptos} + /// + /// Reading `AXFontName` alone therefore drew ghost text in Helvetica over an Aptos document. + func testPrefersReportedFamilyWhenFaceNameContradictsIt() { + let fontInfo: [String: Any] = [ + "AXFontFamily": "Aptos", + "AXFontName": "Helvetica", + "AXFontSize": 12, + "AXVisibleName": "Aptos" + ] + XCTAssertEqual(AXHelper.faceName(fromAXFontDictionary: fontInfo), "Aptos") + } + + func testFallsBackToVisibleNameWhenFamilyMissing() { + let fontInfo: [String: Any] = ["AXFontName": "Helvetica", "AXVisibleName": "Aptos"] + XCTAssertEqual(AXHelper.faceName(fromAXFontDictionary: fontInfo), "Aptos") + } + + func testKeepsSpecificFaceWhenItBelongsToTheReportedFamily() { + // An honest host's PostScript name encodes weight and slant, which the family name loses, + // so the specific face must win whenever the two agree. + let fontInfo: [String: Any] = ["AXFontName": "Helvetica-Bold", "AXFontFamily": "Helvetica"] + XCTAssertEqual(AXHelper.faceName(fromAXFontDictionary: fontInfo), "Helvetica-Bold") + } + + func testKeepsFamilyPrefixedFaceThatIsNotInstalledYet() { + // A host's privately bundled face cannot be instantiated until `HostFontRegistry` loads it, + // so the name test has to stand in for the family check at this point. + let fontInfo: [String: Any] = ["AXFontName": "Aptos-Bold", "AXFontFamily": "Aptos"] + XCTAssertEqual(AXHelper.faceName(fromAXFontDictionary: fontInfo), "Aptos-Bold") + } + + func testUsesFaceNameWhenNoFamilyIsReported() { + // Legacy shape: nothing to cross-check against, so behavior is unchanged. + XCTAssertEqual(AXHelper.faceName(fromAXFontDictionary: ["AXFontName": "Helvetica"]), "Helvetica") + } + + func testIgnoresEmptyNamesAndReturnsNilWhenNothingUsable() { + XCTAssertEqual( + AXHelper.faceName(fromAXFontDictionary: ["AXFontName": "", "AXFontFamily": "Aptos"]), + "Aptos" + ) + XCTAssertNil(AXHelper.faceName(fromAXFontDictionary: ["AXFontSize": 12])) + } } diff --git a/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift b/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift index c01e7ca2..3545dbaf 100644 --- a/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift +++ b/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift @@ -564,4 +564,70 @@ final class GhostSuggestionLayoutTests: XCTestCase { let large = GhostSuggestionLayout.renderedWidth(of: "sample", font: NSFont.systemFont(ofSize: 24)) XCTAssertGreaterThan(large, small) } + + // MARK: - Wrapped lines follow the host's text margin + + /// Word publishes the whole page as one `AXTextArea`, so its `AXFrame` left edge is the paper's + /// edge rather than the document's text margin. Overflow lines anchored to the frame started + /// roughly an inch left of where the host's own text wraps to. + func test_make_overflowLinesAlignToMeasuredContentEdgeWhenAvailable() { + let pageFrame = CGRect(x: 0, y: 0, width: 800, height: 900) + let textMarginX: CGFloat = 140 + + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 700, y: 800, width: 2, height: 18), + inputFrameRect: pageFrame, + observedContentEdges: ObservedContentEdges(leftX: textMarginX, topY: 860) + ) + + let layout = GhostSuggestionLayout.make( + text: " wrapping text that is far too long to fit on the caret's own line", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertGreaterThan(layout.lines.count, 1, "expected the text to wrap") + XCTAssertEqual(layout.panelOriginX, textMarginX, accuracy: 0.001) + } + + func test_make_overflowLinesFallBackToFramePaddingWithoutContentEdge() { + // Unchanged behavior for every host that exposes no measured content edge. + let pageFrame = CGRect(x: 0, y: 0, width: 800, height: 900) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 700, y: 800, width: 2, height: 18), + inputFrameRect: pageFrame, + observedContentEdges: nil + ) + + let layout = GhostSuggestionLayout.make( + text: " wrapping text that is far too long to fit on the caret's own line", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertGreaterThan(layout.lines.count, 1) + XCTAssertGreaterThan(layout.panelOriginX, pageFrame.minX) + XCTAssertLessThan(layout.panelOriginX, 140) + } + + func test_make_contentEdgeOutsideTheFieldIsClampedBackIntoIt() { + // A stale or mis-reported edge must never push ghost text off the field entirely. + let pageFrame = CGRect(x: 100, y: 0, width: 800, height: 900) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 700, y: 800, width: 2, height: 18), + inputFrameRect: pageFrame, + observedContentEdges: ObservedContentEdges(leftX: -5000, topY: 860) + ) + + let layout = GhostSuggestionLayout.make( + text: " wrapping text that is far too long to fit on the caret's own line", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertGreaterThanOrEqual(layout.panelOriginX, pageFrame.minX) + } } diff --git a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift index b9552fcd..2cfb1c0e 100644 --- a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift +++ b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift @@ -166,4 +166,183 @@ final class GhostFontMetricsTests: XCTestCase { ) XCTAssertEqual(size, GhostFontMetrics.absoluteMinimumPointSize, accuracy: 0.0001) } + + // MARK: - Synthetic caret height (AXFrame fallback hosts such as Microsoft Word) + + /// The exact constant `AXTextGeometryResolver.estimatedCaretRect` fabricates when AX exposes only + /// a field frame: `ceil(systemFont(15).ascender - descender + leading)`. It is the same number in + /// every such host, which is precisely why it must not drive font size. + private let syntheticCaretHeight: CGFloat = 18 + + func testSyntheticCaretPrefersHostReportedPointSize() { + // Word at 161% zoom renders 16pt Aptos at roughly 26pt on screen. Whatever the host reports, + // the fabricated 18pt caret must not be what sizing is derived from. + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: metrics(pointSize: 26, ascender: 24.4, descender: -7.3), + hostReportedPointSize: 26, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, 26, accuracy: 0.0001) + } + + func testSyntheticCaretRegressionAgainstFabricatedHeight() { + // Locks in the actual bug: deriving from the synthetic height pinned ghost text at + // 18 * 0.78 = 14.04pt regardless of host size. The new path must not return that. + let buggy = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16 + ) + XCTAssertEqual(buggy, 14.04, accuracy: 0.0001) + + let fixed = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: 26, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(fixed, 26, accuracy: 0.0001) + XCTAssertGreaterThan(fixed, buggy) + } + + func testSyntheticCaretUsesReportedSizeEvenWhenTypefaceFailedToLoad() { + // Word's Aptos is bundled privately, so `NSFont(name:)` can fail while the reported point + // size is still perfectly good. A nil `fieldMetrics` must not discard that size. + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: 20, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, 20, accuracy: 0.0001) + } + + func testSyntheticCaretUsesLooserCeilingThanCaretDerivedCap() { + // A host-reported size is not a rect estimate, so the tight estimated-quality cap (16) must + // not apply to it; only the looser synthetic ceiling bounds it. + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: 200, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, 32, accuracy: 0.0001) + } + + func testSyntheticCaretWithoutReportedSizeKeepsCaretDerivedBehavior() { + // No host size means the fabricated height is all we have; behavior must be unchanged. + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: nil, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, syntheticCaretHeight * fallbackRatio, accuracy: 0.0001) + } + + func testNonSyntheticCaretIgnoresHostReportedPointSize() { + // A measured caret height is real information and must keep winning: hosts report sizes in + // document points, which are wrong under zoom, whereas a measured caret is already on-screen. + let size = GhostFontMetrics.pointSize( + caretHeight: 20, + caretHeightIsSynthetic: false, + fieldMetrics: nil, + hostReportedPointSize: 26, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: maximum, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, 20 * fallbackRatio, accuracy: 0.0001) + } + + func testSyntheticCaretWithNonPositiveReportedSizeFallsBack() { + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: 0, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32 + ) + XCTAssertEqual(size, syntheticCaretHeight * fallbackRatio, accuracy: 0.0001) + } + + func testSizeMultiplierStillAppliesOnSyntheticCaretPath() { + let size = GhostFontMetrics.pointSize( + caretHeight: syntheticCaretHeight, + caretHeightIsSynthetic: true, + fieldMetrics: nil, + hostReportedPointSize: 20, + fallbackRatio: fallbackRatio, + minimum: minimum, + maximum: 16, + syntheticCaretMaximum: 32, + sizeMultiplier: 1.2 + ) + XCTAssertEqual(size, 24, accuracy: 0.0001) + } + + // MARK: - Zoomed hosts + + /// Regression guard for a removed "distrust" heuristic. It compared the measured caret against + /// the glyph box implied by the host's *reported* point size — but the caret is in screen units + /// and the report is in document units, so zoom alone could trip it. Microsoft Word at 164% + /// reports 12pt against a 23pt caret; the correct answer is the font's own ratio applied to the + /// caret (23 * 0.8449 = 19.43, matching the 19.68pt the host actually renders), not a fallback. + func testZoomedHostKeepsTheFontsOwnRatio() { + // Academy Engraved LET: pointSize / (ascender - descender) = 0.8449. + let academy = metrics(pointSize: 12, ascender: 10.0, descender: -4.2059) + let size = GhostFontMetrics.pointSize( + caretHeight: 23, + fieldMetrics: academy, + fallbackRatio: fallbackRatio, + minimum: 11, + maximum: 32 + ) + XCTAssertEqual(size, 23 * (12.0 / 14.2059), accuracy: 0.01) + // The fallback ratio would have produced 17.94 — visibly small against 19.68pt host text. + XCTAssertGreaterThan(size, 23 * fallbackRatio) + } + + func testSameFontAtDifferentZoomsScalesLinearly() { + // The ratio is scale-invariant, so doubling the caret must double the ghost size. This is + // what makes zoom handling free: the caret already carries it. + let font = metrics(pointSize: 12, ascender: 10.0, descender: -4.2059) + let small = GhostFontMetrics.pointSize( + caretHeight: 14.2059, fieldMetrics: font, + fallbackRatio: fallbackRatio, minimum: 1, maximum: 100 + ) + let large = GhostFontMetrics.pointSize( + caretHeight: 28.4118, fieldMetrics: font, + fallbackRatio: fallbackRatio, minimum: 1, maximum: 100 + ) + XCTAssertEqual(small, 12, accuracy: 0.01) + XCTAssertEqual(large, 24, accuracy: 0.01) + } } diff --git a/CotabbyTests/Support/Presentation/Style/GhostFontSizeStabilizerTests.swift b/CotabbyTests/Support/Presentation/Style/GhostFontSizeStabilizerTests.swift index 7e81ac25..fa8cfe89 100644 --- a/CotabbyTests/Support/Presentation/Style/GhostFontSizeStabilizerTests.swift +++ b/CotabbyTests/Support/Presentation/Style/GhostFontSizeStabilizerTests.swift @@ -6,52 +6,80 @@ final class GhostFontSizeStabilizerTests: XCTestCase { func test_firstReadingEstablishesBaseline() { var stabilizer = GhostFontSizeStabilizer() - XCTAssertEqual(stabilizer.stabilizedCaretHeight(18, focusSessionKey: 1), 18) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(18, isPreciseMeasurement: false, focusSessionKey: 1), 18) } func test_largerReadingInSameSessionClampsToMinimum() { var stabilizer = GhostFontSizeStabilizer() - _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 1) + _ = stabilizer.stabilizedCaretHeight(18, isPreciseMeasurement: false, focusSessionKey: 1) // A later poll falls back to the full field height; we keep the smaller real line height. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(120, focusSessionKey: 1), 18) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(120, isPreciseMeasurement: false, focusSessionKey: 1), 18) } func test_smallerReadingLowersMinimumForRestOfSession() { var stabilizer = GhostFontSizeStabilizer() - _ = stabilizer.stabilizedCaretHeight(40, focusSessionKey: 7) - XCTAssertEqual(stabilizer.stabilizedCaretHeight(22, focusSessionKey: 7), 22) + _ = stabilizer.stabilizedCaretHeight(40, isPreciseMeasurement: false, focusSessionKey: 7) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(22, isPreciseMeasurement: false, focusSessionKey: 7), 22) // The new lower floor sticks even when a tall reading returns later in the session. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(90, focusSessionKey: 7), 22) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(90, isPreciseMeasurement: false, focusSessionKey: 7), 22) } func test_sessionChangeResetsBaseline() { var stabilizer = GhostFontSizeStabilizer() - _ = stabilizer.stabilizedCaretHeight(16, focusSessionKey: 1) + _ = stabilizer.stabilizedCaretHeight(16, isPreciseMeasurement: false, focusSessionKey: 1) // Switching fields must not pin a tall field to the previous field's short line height. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(48, focusSessionKey: 2), 48) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(48, isPreciseMeasurement: false, focusSessionKey: 2), 48) } func test_reentryWithNewSessionKeyResetsEvenWhenLarger() { var stabilizer = GhostFontSizeStabilizer() - _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 3) - _ = stabilizer.stabilizedCaretHeight(18, focusSessionKey: 3) + _ = stabilizer.stabilizedCaretHeight(18, isPreciseMeasurement: false, focusSessionKey: 3) + _ = stabilizer.stabilizedCaretHeight(18, isPreciseMeasurement: false, focusSessionKey: 3) // focusChangeSequence increments on focus loss + re-entry, so the larger reading is honored. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(30, focusSessionKey: 4), 30) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(30, isPreciseMeasurement: false, focusSessionKey: 4), 30) } func test_nonPositiveHeightPassesThroughWithoutPoisoningCache() { var stabilizer = GhostFontSizeStabilizer() - _ = stabilizer.stabilizedCaretHeight(20, focusSessionKey: 5) + _ = stabilizer.stabilizedCaretHeight(20, isPreciseMeasurement: false, focusSessionKey: 5) // A transient empty rect should not become the session minimum. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(0, focusSessionKey: 5), 0) - XCTAssertEqual(stabilizer.stabilizedCaretHeight(20, focusSessionKey: 5), 20) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(0, isPreciseMeasurement: false, focusSessionKey: 5), 0) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(20, isPreciseMeasurement: false, focusSessionKey: 5), 20) } func test_genuinelyLargeFieldStaysLarge() { var stabilizer = GhostFontSizeStabilizer() // Every poll agrees the line is tall; nothing should shrink it. - XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, focusSessionKey: 9), 60) - XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, focusSessionKey: 9), 60) - XCTAssertEqual(stabilizer.stabilizedCaretHeight(62, focusSessionKey: 9), 60) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, isPreciseMeasurement: false, focusSessionKey: 9), 60) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(60, isPreciseMeasurement: false, focusSessionKey: 9), 60) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(62, isPreciseMeasurement: false, focusSessionKey: 9), 60) + } + + // MARK: - Precise readings must not be ratcheted + + /// The bug this guards: a user typing in Word at 12pt then switching the document to 20pt kept + /// a caret height pinned to the old session minimum, so ghost text stayed ~40% too small until + /// focus happened to change. Font size and zoom both grow the line box without changing fields. + func test_preciseReadingIsHonoredEvenWhenLargerThanSessionMinimum() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(23, isPreciseMeasurement: true, focusSessionKey: 1) + _ = stabilizer.stabilizedCaretHeight(17, isPreciseMeasurement: true, focusSessionKey: 1) + // Document restyled to 20pt inside the same field: the new, larger measurement wins. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(28, isPreciseMeasurement: true, focusSessionKey: 1), 28) + } + + func test_preciseReadingResetsBaselineForLaterImpreciseReadings() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(17, isPreciseMeasurement: true, focusSessionKey: 1) + _ = stabilizer.stabilizedCaretHeight(28, isPreciseMeasurement: true, focusSessionKey: 1) + // A coarse AXFrame fallback afterwards is still clamped — but to the *current* truth (28), + // not the stale 17, so the flicker protection survives without the ratchet. + XCTAssertEqual(stabilizer.stabilizedCaretHeight(400, isPreciseMeasurement: false, focusSessionKey: 1), 28) + } + + func test_impreciseReadingStillClampsToMinimum() { + var stabilizer = GhostFontSizeStabilizer() + _ = stabilizer.stabilizedCaretHeight(18, isPreciseMeasurement: false, focusSessionKey: 1) + XCTAssertEqual(stabilizer.stabilizedCaretHeight(846, isPreciseMeasurement: false, focusSessionKey: 1), 18) } } diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 9d84d6e1..6aac58b7 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -183,7 +183,8 @@ enum CotabbyTestFixtures { caretQuality: CaretGeometryQuality = .exact, isCaretAtEndOfLine: Bool = true, observedCharWidth: CGFloat? = nil, - isRightToLeft: Bool = false + isRightToLeft: Bool = false, + observedContentEdges: ObservedContentEdges? = nil ) -> SuggestionOverlayGeometry { SuggestionOverlayGeometry( caretRect: caretRect, @@ -191,7 +192,8 @@ enum CotabbyTestFixtures { caretQuality: caretQuality, isCaretAtEndOfLine: isCaretAtEndOfLine, observedCharWidth: observedCharWidth, - isRightToLeft: isRightToLeft + isRightToLeft: isRightToLeft, + observedContentEdges: observedContentEdges ) } From 2c4b13f2540ed3a97bbc9b9dfef26168132cd603 Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:02:10 -0400 Subject: [PATCH 2/6] Address review: absolute size bounds, and narrow the line-margin lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the Greptile and CodeRabbit review, each verified against the code before acting on it. The size floor and ceiling are user-facing settings, so they have to be absolute. `ghostTextSizeMultiplier` was applied after the clamp, letting 1.3x render above the stated ceiling and 0.7x below the stated floor. Now the multiplier scales the caret-derived size and the clamp comes last. An earlier revision scaled last on purpose, so the knob still moved text in a field pinned to a rail; that reasoning predates the rails being settable, and someone who wants smaller text can lower the floor itself. The two bounds are separate UserDefaults keys written one at a time, so a crash between the writes can persist floor > ceiling. `load()` now repairs an inverted pair rather than handing `GhostFontMetrics` a range whose ceiling silently wins. Line-margin lookup, three narrowings: - Skip it entirely when the selection came from a text marker. Those offsets are window-relative, so `AXLineForIndex` would resolve a different visual line and report a margin from the wrong place. Same condition Branch 1 already applies to `AXBoundsForRange`. - Key the cache by paragraph as well as focus session. The measured edge belongs to one visual line, and moving between an indented block, a list item or a table cell changes the margin without changing `focusChangeSequence`, which only turns over when the field's frame does. Counting newlines before the caret is a local scan, so this costs no AX round trip. - Reject a degenerate converted rect. `validatedCocoaTextRect` returns `.zero` for a non-finite AX rect, and with no anchor frame to test against that published an edge at the screen origin. Finally, the placement log's dedup key included the panel's origin, which follows the caret in the inline path — so the line it claimed to emit once per change was emitting on nearly every keystroke. Co-Authored-By: Claude Opus 5 --- .../Resolution/AXTextGeometryResolver.swift | 7 ++++ .../Resolution/FocusSnapshotResolver.swift | 23 ++++++++-- .../Presentation/OverlayController.swift | 6 +-- .../Presentation/Style/GhostFontMetrics.swift | 11 ++++- .../Settings/SuggestionSettingsStore.swift | 11 ++++- .../SuggestionSettingsModelTests.swift | 12 ++++++ .../Style/GhostFontMetricsTests.swift | 42 +++++++++++++++++++ 7 files changed, 102 insertions(+), 10 deletions(-) diff --git a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift index 0355ec0d..46c5288f 100644 --- a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift @@ -247,6 +247,13 @@ struct AXTextGeometryResolver { fromAccessibilityRect: rect, anchorFrame: anchorFrame ) + // `validatedCocoaTextRect` returns `.zero` for a non-finite AX rect, and with no anchor frame + // to check against that would publish an edge at the screen origin — anchoring ghost text to + // the corner of the display. Reject the degenerate rect before the anchor test, so the guard + // does not depend on an anchor frame being present. + guard AXHelper.rectHasFiniteComponents(cocoaRect), !cocoaRect.isEmpty else { + return nil + } // A line rect that escapes the field is a mis-reported range, not a margin; ignore it rather // than anchoring ghost text somewhere the host is not drawing. if let anchorFrame, !anchorFrame.isEmpty, !anchorFrame.insetBy(dx: -1, dy: -1).intersects(cocoaRect) { diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 88a115a6..24b7d73e 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -823,9 +823,26 @@ struct FocusSnapshotResolver { // caret comes from `AXBoundsForRange` never walk those runs, so fall back to asking the host // directly for its line geometry — that is the only way to learn a document's text margin as // distinct from its page edge. - let observedContentEdges = caretResult?.observedContentEdges ?? selectionForGeometry.flatMap { selection in - lineContentEdgesCache.value( - forKey: "lineEdges:\(AXHelper.elementIdentity(for: element))", + // Only ask for line geometry when the offset means what the host thinks it means. A + // marker-synthesized selection is window-relative (see Branch 1's gate above), so handing it + // to `AXLineForIndex` resolves some other visual line and yields a margin from the wrong + // place entirely. + let lineQueryOffsetIsDocumentRelative = markerSelection == nil + let observedContentEdges = caretResult?.observedContentEdges + ?? (lineQueryOffsetIsDocumentRelative ? selectionForGeometry : nil).flatMap { selection in + // Keyed by paragraph as well as focus session. The measured edge belongs to one visual + // line, and moving between paragraphs inside the same field — an indented block, a list + // item, a table cell — changes the margin without changing `focusChangeSequence`, which + // only turns over when the field's frame does. Counting newlines before the caret is a + // local string scan, so the extra precision costs no AX round trip. + let paragraphSource = (textValue ?? "") as NSString + let paragraphIndex = paragraphSource + .substring(to: min(max(selection.location, 0), paragraphSource.length)) + .reduce(into: 0) { count, character in + if character.isNewline { count += 1 } + } + return lineContentEdgesCache.value( + forKey: "lineEdges:\(AXHelper.elementIdentity(for: element)):p\(paragraphIndex)", focusChangeSequence: focusChangeSequence ) { geometryResolver.resolveLineContentEdges( diff --git a/Cotabby/Services/Presentation/OverlayController.swift b/Cotabby/Services/Presentation/OverlayController.swift index a5c4f8fc..15c436b9 100644 --- a/Cotabby/Services/Presentation/OverlayController.swift +++ b/Cotabby/Services/Presentation/OverlayController.swift @@ -85,7 +85,8 @@ final class OverlayController: SuggestionOverlayControlling { private var lastLoggedFontSignature: String? /// Same idea for the placement line: inline ghost text re-renders on every keystroke, and the - /// caret X changes each time, so the signature deliberately excludes it — what is worth one line + /// caret X changes each time, so the signature excludes every value that tracks the caret — + /// including the panel's own origin, which follows it in the inline path. What is worth one line /// per change is the *shape* of the placement, not the fact that the caret moved. private var lastLoggedPlacementSignature: String? @@ -606,8 +607,7 @@ final class OverlayController: SuggestionOverlayControlling { String(format: "%.0f", caretRect.height), String(format: "%.0f", contentSize.height), String(layout.lines.count), - String(usedContentEdge), - String(format: "%.0f", panelFrame.minX) + String(usedContentEdge) ].joined(separator: "|") guard signature != lastLoggedPlacementSignature else { return } lastLoggedPlacementSignature = signature diff --git a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift index 971b22c7..01b06753 100644 --- a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift +++ b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift @@ -88,8 +88,15 @@ enum GhostFontMetrics { ceiling = maximum } - let autoSize = min(max(minimum, base), ceiling) - return max(absoluteMinimumPointSize, autoSize * sizeMultiplier) + // Scale first, then clamp. `minimum` and `maximum` are the user's "Smallest/Largest Ghost + // Text" settings, so they have to be absolute: clamping before the multiplier let a 1.3x + // knob render above the stated ceiling and a 0.7x knob below the stated floor, which makes + // both controls lie. An earlier revision deliberately scaled last so the knob still moved + // text in fields pinned to a rail; that reasoning predates the rails being user-settable, + // and someone who wants smaller text can now lower the floor itself. + let scaled = base * sizeMultiplier + let clamped = min(max(minimum, scaled), ceiling) + return max(absoluteMinimumPointSize, clamped) } /// `pointSize / (ascender - descender)` for the field font, or nil when the metrics are unusable. diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index c2582314..780d1a9f 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -316,6 +316,13 @@ struct SuggestionSettingsStore { } else { Self.clampedGhostFontSizeCeiling(userDefaults.double(forKey: Self.ghostFontSizeCeilingDefaultsKey)) } + // The two bounds are separate keys written one at a time, so a crash between the writes can + // leave floor > ceiling on disk. `GhostFontMetrics` would then clamp with an inverted range + // and the ceiling would silently win every time, so repair the pair here rather than trust + // that the setters always completed. + let normalizedGhostFontSizeCeiling = max(resolvedGhostFontSizeCeiling, resolvedGhostFontSizeFloor) + let normalizedGhostFontSizeFloor = min(resolvedGhostFontSizeFloor, normalizedGhostFontSizeCeiling) + let resolvedEngine = userDefaults .string(forKey: Self.selectedEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) @@ -603,8 +610,8 @@ struct SuggestionSettingsStore { customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, ghostTextOpacity: resolvedGhostTextOpacity, ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, - ghostFontSizeFloor: resolvedGhostFontSizeFloor, - ghostFontSizeCeiling: resolvedGhostFontSizeCeiling, + ghostFontSizeFloor: normalizedGhostFontSizeFloor, + ghostFontSizeCeiling: normalizedGhostFontSizeCeiling, isMenuBarIconVisible: resolvedMenuBarIconVisible, isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, mirrorPreference: resolvedMirrorPreference, diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift index 5f8159cc..eaaf88e5 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift @@ -746,4 +746,16 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(snapshot.disabledAppBundleIdentifiers, ["com.example.app"]) XCTAssertEqual(snapshot.extendedContext, "context body") } + + func test_invertedGhostFontBoundsOnDiskAreRepairedOnLoad() { + // The two bounds are separate UserDefaults keys written one at a time, so a crash between + // the writes can persist floor > ceiling. Loading that pair unrepaired would hand + // GhostFontMetrics an inverted range where the ceiling silently wins. + defaults.set(40.0, forKey: "cotabbyGhostFontSizeFloor") + defaults.set(16.0, forKey: "cotabbyGhostFontSizeCeiling") + + let model = makeModel() + + XCTAssertLessThanOrEqual(model.ghostFontSizeFloor, model.ghostFontSizeCeiling) + } } diff --git a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift index 2cfb1c0e..e333420b 100644 --- a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift +++ b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift @@ -345,4 +345,46 @@ final class GhostFontMetricsTests: XCTestCase { XCTAssertEqual(small, 12, accuracy: 0.01) XCTAssertEqual(large, 24, accuracy: 0.01) } + + // MARK: - The user's bounds are absolute + + /// "Smallest Ghost Text" and "Largest Ghost Text" are user-facing settings, so a size multiplier + /// must not carry the result past them. Clamping before the multiplier let 1.3x render above the + /// stated ceiling and 0.7x below the stated floor. + func testSizeMultiplierCannotExceedTheCeiling() { + let size = GhostFontMetrics.pointSize( + caretHeight: 60, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: 11, + maximum: 48, + sizeMultiplier: 1.3 + ) + XCTAssertEqual(size, 48, accuracy: 0.0001) + } + + func testSizeMultiplierCannotFallBelowTheFloor() { + let size = GhostFontMetrics.pointSize( + caretHeight: 14, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: 11, + maximum: 48, + sizeMultiplier: 0.7 + ) + XCTAssertEqual(size, 11, accuracy: 0.0001) + } + + func testSizeMultiplierStillScalesBetweenTheBounds() { + // Away from the rails the knob must still do its job: 20 * 0.78 * 1.2. + let size = GhostFontMetrics.pointSize( + caretHeight: 20, + fieldMetrics: nil, + fallbackRatio: fallbackRatio, + minimum: 11, + maximum: 48, + sizeMultiplier: 1.2 + ) + XCTAssertEqual(size, 20 * fallbackRatio * 1.2, accuracy: 0.0001) + } } From 369d9a14c1be74f55e8fa1375fb7b93bdf2ebf2e Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:06:16 -0400 Subject: [PATCH 3/6] Keep line-query content edges from inheriting run-measured trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `layoutRepairedAnchor` skips its layout repair for a web field whose caret is `.derived` and whose content edges exist, on the grounds that child text-run frames carry the host's real line positions. Content edges can now also come from the host's line-query attributes, which describe a left margin but say nothing about which visual line the caret is on — so a wrong-line web caret could skip the repair that exists to correct it. `ObservedContentEdges` now records whether it was run-measured, and only that provenance buys the skip. It defaults to false so a future source has to opt in deliberately rather than inherit an exemption it did not earn. Co-Authored-By: Claude Opus 5 --- .../SuggestionCoordinator+Acceptance.swift | 7 +++++-- Cotabby/Models/Focus/FocusModels.swift | 13 +++++++++++++ .../Focus/Resolution/AXTextGeometryResolver.swift | 5 +++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift index 7b030fa9..31295d32 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift @@ -813,8 +813,11 @@ extension SuggestionCoordinator { ) } // Run-measured derived rects are kept unconditionally: run frames carry the host's - // real line positions, including blank lines some hosts omit from the AX text. - if context.observedContentEdges != nil { + // real line positions, including blank lines some hosts omit from the AX text. The + // provenance check matters because content edges can now also come from the host's + // line-query attributes, which describe a left margin but carry no line information — + // letting those skip the repair would leave a wrong-line web caret uncorrected. + if context.observedContentEdges?.isRunMeasured == true { return LayoutRepairedAnchor( rect: fallbackRect, quality: .derived, outcome: nil, skipReason: .runMeasuredGeometry ) diff --git a/Cotabby/Models/Focus/FocusModels.swift b/Cotabby/Models/Focus/FocusModels.swift index 7bfdf42e..309036d3 100644 --- a/Cotabby/Models/Focus/FocusModels.swift +++ b/Cotabby/Models/Focus/FocusModels.swift @@ -133,6 +133,19 @@ nonisolated struct ObservedContentEdges: Equatable, Sendable { let leftX: CGFloat /// Global Cocoa-coordinate top edge (maxY) of the topmost text run. let topY: CGFloat + /// True only when these edges came from walking the host's child text-run frames. Those frames + /// carry the host's real line positions, which is why `layoutRepairedAnchor` lets them outrank + /// its own layout estimate for a web field. Edges obtained any other way — the host's line-query + /// attributes, for instance — describe a margin but say nothing about which visual line the + /// caret is on, so they must not buy that same trust. Defaults to `false` so a future source has + /// to opt in deliberately rather than inherit an exemption it did not earn. + let isRunMeasured: Bool + + init(leftX: CGFloat, topY: CGFloat, isRunMeasured: Bool = false) { + self.leftX = leftX + self.topY = topY + self.isRunMeasured = isRunMeasured + } } /// This snapshot is the future handoff point into suggestion generation. diff --git a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift index 46c5288f..85397447 100644 --- a/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift @@ -395,7 +395,7 @@ struct AXTextGeometryResolver { let contentEdges: ObservedContentEdges? if let leftX = cocoaRunFrames.map(\.minX).min(), let topY = cocoaRunFrames.map(\.maxY).max() { - contentEdges = ObservedContentEdges(leftX: leftX, topY: topY) + contentEdges = ObservedContentEdges(leftX: leftX, topY: topY, isRunMeasured: true) } else { contentEdges = nil } @@ -446,7 +446,8 @@ struct AXTextGeometryResolver { quality: .derived, observedContentEdges: ObservedContentEdges( leftX: unionFrame.minX, - topY: unionFrame.maxY + topY: unionFrame.maxY, + isRunMeasured: true ), sourceDetail: "wrapped-run-character-bounds" ) From ad3d1e4bd41e7dcf795861e4e7d2a9c9dd372898 Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:10:23 -0400 Subject: [PATCH 4/6] Redraw inline ghost text once a host font finishes registering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Font registration is asynchronous, and nothing triggered a redraw when it completed. "The next render picks it up" only holds while something else is still causing renders: a suggestion that arrives complete, with no streaming and no further keystrokes, is drawn once in the fallback font and stays there until an unrelated later suggestion happens to redraw it. Re-showing is cheap and idempotent — `showInline` recomputes from the same text and geometry, and the fade is owned by `showSuggestion`, so nothing re-animates. Guarded on the font being resolvable now, so a registration that reports success but leaves the name unusable cannot loop. Co-Authored-By: Claude Opus 5 --- .../Presentation/OverlayController.swift | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/Cotabby/Services/Presentation/OverlayController.swift b/Cotabby/Services/Presentation/OverlayController.swift index 15c436b9..a3180e81 100644 --- a/Cotabby/Services/Presentation/OverlayController.swift +++ b/Cotabby/Services/Presentation/OverlayController.swift @@ -660,12 +660,39 @@ final class OverlayController: SuggestionOverlayControlling { // re-enter, but the Task allocation and actor hop are not free on the hot path. let requestKey = "\(bundleIdentifier)|\(name)" guard requestedHostFonts.insert(requestKey).inserted else { return nil } - Task { - await HostFontRegistry.shared.ensureFontAvailable(named: name, bundleIdentifier: bundleIdentifier) + Task { [weak self] in + let registered = await HostFontRegistry.shared.ensureFontAvailable( + named: name, + bundleIdentifier: bundleIdentifier + ) + guard registered else { return } + self?.redrawInlineAfterFontRegistration(fontName: name) } return nil } + /// Re-renders a visible inline suggestion once a host font finishes registering. + /// + /// Without this, "the next render picks it up" is only true while something else is still + /// causing renders. A suggestion that arrived complete — no streaming, no further keystrokes — + /// is drawn once, in the fallback font, and stays that way until an unrelated later suggestion + /// happens to redraw it. Re-showing here is cheap and idempotent: `showInline` recomputes from + /// the same text and geometry, and the fade is owned by `showSuggestion`, so nothing re-animates. + /// + /// Guarded on the font actually being resolvable now, so a registration that reported success + /// but left the name unusable cannot cause a pointless redraw loop. + private func redrawInlineAfterFontRegistration(fontName: String) { + guard case .visible(let text, let geometry, let mode) = state, + mode == .inline, + geometry.resolvedFieldStyle?.fontName == fontName, + NSFont(name: fontName, size: Layout.metricProbeFontSize) != nil + else { + return + } + + showInline(text: text, geometry: geometry) + } + /// Maps the host field's foreground color to a ghost color, or nil to fall back to the default /// gray. Near-white / near-black extremes are treated as untrustworthy (some browsers report the /// page background as the text color) and fall back, so ghost text never renders invisibly. From c0751ef63dc2e0dc14e8b79363ff9b61ed1ee783 Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:08:30 -0400 Subject: [PATCH 5/6] Fix tests broken by earlier review fixes, and two remaining findings The previous rounds were validated with build-for-testing only, on the belief that the app-hosted test bundle could not run locally. It can, with CODE_SIGNING_ALLOWED=NO, and doing so showed twelve failures introduced by this PR's own changes. All are fixed and the full suite now passes. Tests updated to the intended behavior, not to whatever the code emits: - Layout tests encoded the removed 6pt caret gap (first-line indent 2, a 43/17 wrap split) and the old `fontSize * 1.25` placement. The flush anchor and rendered-line-height placement are deliberate, so the expectations now describe them, with the arithmetic in the comments corrected to match. - Multiplier tests encoded the old clamp-then-scale order. They now pin the absolute-bound contract, and the absolute-floor test lowers the user floor so the backstop it names can actually bind. - The run-measured layout-repair test now declares its fixture's provenance, and a counterpart test pins that line-query edges do not get that skip. Remaining review findings: - The paragraph cache key compared a document-relative caret against a bounded, window-relative text window, so different paragraphs could share a key. The paragraph start is now found in window coordinates and shifted by the window's document origin. - `used_host_content_edge` reported whether a margin was measured, not whether the panel used it. The layout now records its actual anchor choice. - Both overlay diagnostics now skip signature and metric work entirely when debug logging is off, since every inline render reaches them. - Corrected two doc comments left stale by earlier changes. Co-Authored-By: Claude Opus 5 --- .../Resolution/FocusSnapshotResolver.swift | 49 ++++++---- .../Presentation/OverlayController.swift | 21 +++-- .../Geometry/GhostSuggestionLayout.swift | 47 +++++++--- .../Presentation/Style/GhostFontMetrics.swift | 11 ++- .../SuggestionCaretLayoutRepairTests.swift | 30 +++++- .../Geometry/GhostSuggestionLayoutTests.swift | 91 ++++++++++++++++--- .../Style/GhostFontMetricsTests.swift | 25 ++--- 7 files changed, 205 insertions(+), 69 deletions(-) diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 24b7d73e..f23eb484 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -41,8 +41,9 @@ struct FocusSnapshotResolver { private let terminalDetectionCache = FocusSessionScopedCache() /// Where the host actually starts drawing text on the caret's line, which a field's /// `AXFrame` does not reveal (Word's frame is the page edge, not the text margin). Three AX - /// round trips, so it is resolved once per focus session; the margin cannot move without the - /// field's frame moving, which already bumps `focusChangeSequence`. + /// round trips, so each result is cached per focus session *and* per paragraph: the margin + /// changes between an indented block, a list item or a table cell inside one field without + /// `focusChangeSequence` turning over. The lookup site documents how the paragraph key is built. private let lineContentEdgesCache = FocusSessionScopedCache() /// Every parameterized attribute `resolveLineContentEdges` needs. All three must be /// advertised before it runs; see that method for why an ungated call is a stall risk. @@ -829,25 +830,41 @@ struct FocusSnapshotResolver { // place entirely. let lineQueryOffsetIsDocumentRelative = markerSelection == nil let observedContentEdges = caretResult?.observedContentEdges - ?? (lineQueryOffsetIsDocumentRelative ? selectionForGeometry : nil).flatMap { selection in - // Keyed by paragraph as well as focus session. The measured edge belongs to one visual - // line, and moving between paragraphs inside the same field — an indented block, a list - // item, a table cell — changes the margin without changing `focusChangeSequence`, which - // only turns over when the field's frame does. Counting newlines before the caret is a - // local string scan, so the extra precision costs no AX round trip. - let paragraphSource = (textValue ?? "") as NSString - let paragraphIndex = paragraphSource - .substring(to: min(max(selection.location, 0), paragraphSource.length)) - .reduce(into: 0) { count, character in - if character.isNewline { count += 1 } - } + ?? (lineQueryOffsetIsDocumentRelative ? selectionForGeometry : nil).flatMap { + geometrySelection -> ObservedContentEdges? in + // Keyed by the document offset where the caret's paragraph starts, alongside the focus + // session. The measured edge belongs to one visual line, and moving between paragraphs + // inside the same field — an indented block, a list item, a table cell — changes the + // margin without changing `focusChangeSequence`, which only turns over when the field's + // frame does. Finding the paragraph start is a local string scan, so no AX round trip. + // + // The offsets must agree on units. `textValue` can be a bounded window around the caret, + // indexed by the window-relative `selection`, while `geometrySelection` is document- + // relative. Measuring the window against the document offset overshoots it and lets + // different paragraphs share a key, so the paragraph start is found in window coordinates + // and shifted by the window's document origin. If the paragraph begins before the window, + // the key falls back to that origin, which moves as the window slides: a cache miss and a + // fresh lookup, never another paragraph's edge. + guard let windowSelection = selection, let windowText = textValue else { return nil } + let window = windowText as NSString + let caretInWindow = min(max(windowSelection.location, 0), window.length) + let newlineBeforeCaret = window.rangeOfCharacter( + from: .newlines, + options: .backwards, + range: NSRange(location: 0, length: caretInWindow) + ) + let paragraphStartInWindow = newlineBeforeCaret.location == NSNotFound + ? 0 + : NSMaxRange(newlineBeforeCaret) + let windowDocumentOrigin = geometrySelection.location - windowSelection.location + let paragraphDocumentStart = windowDocumentOrigin + paragraphStartInWindow return lineContentEdgesCache.value( - forKey: "lineEdges:\(AXHelper.elementIdentity(for: element)):p\(paragraphIndex)", + forKey: "lineEdges:\(AXHelper.elementIdentity(for: element)):p\(paragraphDocumentStart)", focusChangeSequence: focusChangeSequence ) { geometryResolver.resolveLineContentEdges( for: element, - caretLocation: selection.location, + caretLocation: geometrySelection.location, anchorFrame: inputFrameRect, // Read from the attribute list already fetched for this element, so the gate // adds no round trip. Hosts that resolve their caret through text markers diff --git a/Cotabby/Services/Presentation/OverlayController.swift b/Cotabby/Services/Presentation/OverlayController.swift index a3180e81..046a10c9 100644 --- a/Cotabby/Services/Presentation/OverlayController.swift +++ b/Cotabby/Services/Presentation/OverlayController.swift @@ -318,8 +318,7 @@ final class OverlayController: SuggestionOverlayControlling { contentSize: contentSize, layout: layout, renderFont: renderFont, - fontSize: fontSize, - geometryObservedContentEdges: geometry.observedContentEdges + fontSize: fontSize ) // Capture exactly what this inline render used, so a subsequent `advanceInline` slides the @@ -534,6 +533,9 @@ final class OverlayController: SuggestionOverlayControlling { referenceFieldFont: NSFont?, fontSize: CGFloat ) { + // Every inline render reaches this; bail before building the signature so the default, + // non-debug configuration pays nothing for a diagnostic it will never emit. + guard CotabbyLogger.suggestion.logLevel <= .debug else { return } let style = geometry.resolvedFieldStyle let signature = [ geometry.bundleIdentifier ?? "-", @@ -585,9 +587,11 @@ final class OverlayController: SuggestionOverlayControlling { contentSize: CGSize, layout: GhostSuggestionLayout, renderFont: NSFont?, - fontSize: CGFloat, - geometryObservedContentEdges: ObservedContentEdges? + fontSize: CGFloat ) { + // Same reasoning as `logGhostFontResolution`: skip the font metrics and signature work + // entirely unless this line can actually be emitted. + guard CotabbyLogger.suggestion.logLevel <= .debug else { return } let font = renderFont ?? NSFont.systemFont(ofSize: fontSize) // Text sits on its baseline, which is `descent` above the bottom of its own line box. let ghostDescent = -font.descender @@ -598,10 +602,11 @@ final class OverlayController: SuggestionOverlayControlling { let hostDescent = ghostDescent * (caretRect.height / max(contentSize.height, 1)) let hostBaselineY = caretRect.minY + hostDescent - // Whether the wrapped-line anchor came from the host's measured text margin or fell back to - // the field frame. Without this, "ghost text ignores the document margin" is unanswerable - // from logs: both outcomes just look like an X coordinate. - let usedContentEdge = geometryObservedContentEdges != nil + // Whether the panel actually anchored to the host's measured text margin. Read from the + // layout's own anchor decision, not from whether edges were measured at all: a single-line + // suggestion anchors at the caret even when a margin exists, so "measured" and "used" + // differ, and only "used" answers whether ghost text followed the document margin. + let usedContentEdge = layout.panelAnchoredToHostContentEdge let signature = [ String(format: "%.0f", caretRect.height), diff --git a/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift b/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift index d38a2991..4102fe08 100644 --- a/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift +++ b/Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift @@ -25,6 +25,11 @@ struct GhostSuggestionLayout: Equatable { let lineHeight: CGFloat let topLineCenterOffsetFromCaret: CGFloat let isRightToLeft: Bool + /// True when the panel's origin is the host's measured text margin rather than the caret or the + /// field frame. Diagnostics read this instead of inferring it from whether a measurement merely + /// existed: a single-line suggestion anchors at the caret, and a frame too narrow to use falls + /// back to the caret region, so "a margin was measured" does not mean "the margin was used". + var panelAnchoredToHostContentEdge: Bool = false private enum Metrics { static let caretGap: CGFloat = 6 @@ -62,10 +67,11 @@ struct GhostSuggestionLayout: Equatable { ) // When the keycap is hidden the text can use the full width, so we stop reserving room for it. let keycapReservation = showsAcceptanceHint ? Metrics.estimatedKeycapAndSpacingWidth : 0 - let usableFrame = usableTextFrame( + let usable = usableTextFrame( geometry: geometry, visibleFrame: visibleFrame ) + let usableFrame = usable.frame // Direction-dependent anchor and budget. // LTR: anchor at the right edge of the caret, budget extends rightward. @@ -116,7 +122,11 @@ struct GhostSuggestionLayout: Equatable { panelOriginX: firstLineAnchor, lineHeight: lineHeight, topLineCenterOffsetFromCaret: 0, - isRightToLeft: isRTL + isRightToLeft: isRTL, + // A single line anchors at the caret; the margin only wins when it lies past the caret. + panelAnchoredToHostContentEdge: usable.usesHostContentEdge + && !isRTL + && firstLineAnchor == usableFrame.minX ) } @@ -182,7 +192,9 @@ struct GhostSuggestionLayout: Equatable { panelOriginX: panelOriginX, lineHeight: lineHeight, topLineCenterOffsetFromCaret: startsBelowCaret ? -lineHeight : 0, - isRightToLeft: isRTL + isRightToLeft: isRTL, + // LTR wrapped panels start at the usable frame's left edge, which is the margin if one fed it. + panelAnchoredToHostContentEdge: usable.usesHostContentEdge && !isRTL ) } @@ -209,7 +221,7 @@ struct GhostSuggestionLayout: Equatable { private static func usableTextFrame( geometry: SuggestionOverlayGeometry, visibleFrame: CGRect - ) -> CGRect { + ) -> (frame: CGRect, usesHostContentEdge: Bool) { if let inputFrame = geometry.inputFrameRect?.standardized, inputFrame.width > Metrics.minimumLineWidth { // A measured content edge is the host's real text margin, so it needs no padding guess. @@ -231,11 +243,15 @@ struct GhostSuggestionLayout: Equatable { ) if maxX - minX > Metrics.minimumLineWidth { - return CGRect( - x: minX, - y: inputFrame.minY, - width: maxX - minX, - height: inputFrame.height + return ( + CGRect( + x: minX, + y: inputFrame.minY, + width: maxX - minX, + height: inputFrame.height + ), + // The screen margin can override the measured edge; only report it when it survived. + contentLeftX.map { $0 == minX } ?? false ) } } @@ -252,11 +268,14 @@ struct GhostSuggestionLayout: Equatable { fallbackMaxX = visibleFrame.maxX - Metrics.fallbackScreenMargin } - return CGRect( - x: fallbackMinX, - y: geometry.caretRect.minY, - width: max(Metrics.minimumLineWidth, fallbackMaxX - fallbackMinX), - height: geometry.caretRect.height + return ( + CGRect( + x: fallbackMinX, + y: geometry.caretRect.minY, + width: max(Metrics.minimumLineWidth, fallbackMaxX - fallbackMinX), + height: geometry.caretRect.height + ), + false ) } diff --git a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift index 01b06753..73fc46ff 100644 --- a/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift +++ b/Cotabby/Support/Presentation/Style/GhostFontMetrics.swift @@ -42,11 +42,12 @@ enum GhostFontMetrics { } /// `sizeMultiplier` is the user's Appearance "Ghost Text Size" knob. It scales the - /// caret-approximated size *after* the `[minimum, maximum]` clamp, so the knob reliably resizes - /// ghost text even for fields that auto-size onto those rails; applying it before the clamp would - /// make a "smaller" choice a no-op whenever the field already sits at `minimum`. Growth is bounded - /// by the caller's clamped multiplier rather than a second ceiling here; only the absolute floor - /// is re-applied so a low multiplier can never produce illegibly small text. + /// caret-approximated size *before* the `[minimum, maximum]` clamp, because `minimum` and + /// `maximum` are the user's "Smallest/Largest Ghost Text" settings and have to be absolute. The + /// trade-off is deliberate: in a field already pinned to a rail the knob cannot move text past + /// that rail, and lowering the floor or raising the ceiling is how a user asks for that. + /// `absoluteMinimumPointSize` is re-applied last as a backstop that only binds when the user's + /// floor sits below it. /// /// `caretHeightIsSynthetic` marks the case where `caretHeight` is not a measurement at all. On /// the `AXFrame` fallback path the resolver has no text-range geometry to read, so it fabricates diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCaretLayoutRepairTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCaretLayoutRepairTests.swift index c6be57bf..8314552a 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCaretLayoutRepairTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCaretLayoutRepairTests.swift @@ -228,7 +228,7 @@ final class SuggestionCaretLayoutRepairTests: XCTestCase { caretRect: axRect, inputFrameRect: frame, caretQuality: .derived, - observedContentEdges: ObservedContentEdges(leftX: 4, topY: 116), + observedContentEdges: ObservedContentEdges(leftX: 4, topY: 116, isRunMeasured: true), precedingText: "Hello", isWebContentField: true ) @@ -246,6 +246,34 @@ final class SuggestionCaretLayoutRepairTests: XCTestCase { XCTAssertEqual(anchor.skipReason, .runMeasuredGeometry) } + func test_layoutRepair_lineQueryEdgesDoNotBuyTheRunMeasuredSkip() { + // Same wrong-line derived web caret, but these edges came from the host's line-query + // attributes rather than child-run frames. They describe a left margin and say nothing about + // which visual line the caret is on, so they must not skip the repair: a wrong-line caret + // that skipped it would stay wrong. Only run-measured provenance earns the exemption above. + let frame = CGRect(x: 0, y: 0, width: 300, height: 120) + let axRect = CGRect(x: 50, y: 52, width: 2, height: 16) + let context = CotabbyTestFixtures.focusedInputContext( + caretRect: axRect, + inputFrameRect: frame, + caretQuality: .derived, + observedContentEdges: ObservedContentEdges(leftX: 4, topY: 116, isRunMeasured: false), + precedingText: "Hello", + isWebContentField: true + ) + + let anchor = SuggestionCoordinator.layoutRepairedAnchor( + for: context, + fallbackRect: axRect, + pendingInsertion: "", + isRightToLeft: false + ) + + XCTAssertNotEqual(anchor.skipReason, .runMeasuredGeometry) + XCTAssertNotNil(anchor.outcome, "the estimator must run rather than be skipped") + XCTAssertEqual(anchor.quality, .layoutEstimated) + } + func test_layoutRepair_derivedKeepsAXRectWhenEstimatorRejects() { let axRect = CGRect(x: 50, y: 8, width: 2, height: 16) let context = CotabbyTestFixtures.focusedInputContext( diff --git a/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift b/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift index 3545dbaf..c038e97b 100644 --- a/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift +++ b/CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift @@ -193,9 +193,12 @@ final class GhostSuggestionLayoutTests: XCTestCase { // Panel X should match panelOriginX XCTAssertEqual(frame.origin.x, layout.panelOriginX) - // Panel should be vertically centered around the caret midY + // The top line is centered on the caret midY using the height the text actually rendered at + // (`contentSize.height / lines.count`), not the `fontSize * 1.25` estimate in `lineHeight`: + // the two disagree in practice, and positioning by the estimate shifted the ghost vertically. let expectedTopCenter = caretRect.midY + layout.topLineCenterOffsetFromCaret - let expectedY = expectedTopCenter - contentSize.height + (layout.lineHeight / 2) + let renderedLineHeight = contentSize.height / CGFloat(layout.lines.count) + let expectedY = expectedTopCenter - contentSize.height + (renderedLineHeight / 2) XCTAssertEqual(frame.origin.y, expectedY) } @@ -262,8 +265,9 @@ final class GhostSuggestionLayoutTests: XCTestCase { // RTL: actual origin.x = panelOriginX - contentSize.width XCTAssertEqual(frame.origin.x, layout.panelOriginX - contentSize.width) - // Panel should be entirely to the left of the caret - XCTAssertLessThan(frame.maxX, geometry.caretRect.minX) + // Panel sits entirely left of the caret and flush against it: inline ghost text starts at the + // caret edge with no artificial gap, since the suggestion carries its own leading space. + XCTAssertEqual(frame.maxX, geometry.caretRect.minX) } // MARK: - RTL multi-line layout @@ -336,8 +340,8 @@ final class GhostSuggestionLayoutTests: XCTestCase { // MARK: - Explicit newlines func test_make_explicitNewlineForcesLineBreakAtThatPoint() { - // usable frame: minX = max(0 + 8, 0 + 16) = 16; caret anchor = 12 + 6 = 18, so the first - // line is indented 2pt from the panel origin and the wrapped line starts at the origin. + // usable frame: minX = max(0 + 8, 0 + 16) = 16; caret anchor = max(caret maxX 12, 16) = 16 + // (no artificial caret gap), so the first line starts at the panel origin like the wrapped one. let geometry = CotabbyTestFixtures.overlayGeometry( caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30), @@ -352,7 +356,7 @@ final class GhostSuggestionLayoutTests: XCTestCase { ) XCTAssertEqual(layout.lines.map(\.text), ["hello", "world"]) - XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.lines[0].leadingIndent, 0) XCTAssertEqual(layout.lines[1].leadingIndent, 0) XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) XCTAssertEqual(layout.panelOriginX, 16) @@ -375,7 +379,7 @@ final class GhostSuggestionLayoutTests: XCTestCase { ) XCTAssertEqual(layout.lines.map(\.text), ["world"]) - XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.lines[0].leadingIndent, 0) XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) } @@ -403,8 +407,8 @@ final class GhostSuggestionLayoutTests: XCTestCase { } func test_make_overwideSegmentBeforeNewlineWidthWrapsAndCarriesRemainder() { - // usable: minX 16, maxX 492; first-line budget = 492 - 18 - 36 (keycap) = 438; at 10pt per - // char the 60-char segment splits after 43 chars, and the leftover 17 chars must carry + // usable: minX 16, maxX 492; first-line budget = 492 - 16 - 36 (keycap) = 440; at 10pt per + // char the 60-char segment splits after 44 chars, and the leftover 16 chars must carry // forward together with the post-newline text as separate lines. let geometry = CotabbyTestFixtures.overlayGeometry( caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), @@ -421,9 +425,9 @@ final class GhostSuggestionLayoutTests: XCTestCase { XCTAssertEqual( layout.lines.map(\.text), - [String(repeating: "a", count: 43), String(repeating: "a", count: 17), "rest"] + [String(repeating: "a", count: 44), String(repeating: "a", count: 16), "rest"] ) - XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.lines[0].leadingIndent, 0) XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) XCTAssertEqual(layout.lines.last?.showsKeycap, true) } @@ -445,7 +449,7 @@ final class GhostSuggestionLayoutTests: XCTestCase { ) XCTAssertEqual(layout.lines.map(\.text), ["W", "n", "e", "x", "t"]) - XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.lines[0].leadingIndent, 0) } func test_make_trailingNewlineAfterOverwideSegmentKeepsWidthWrappedRemainder() { @@ -466,7 +470,7 @@ final class GhostSuggestionLayoutTests: XCTestCase { XCTAssertEqual( layout.lines.map(\.text), - [String(repeating: "a", count: 43), String(repeating: "a", count: 17)] + [String(repeating: "a", count: 44), String(repeating: "a", count: 16)] ) } @@ -630,4 +634,63 @@ final class GhostSuggestionLayoutTests: XCTestCase { XCTAssertGreaterThanOrEqual(layout.panelOriginX, pageFrame.minX) } + + // MARK: - Anchor provenance for diagnostics + + /// `used_host_content_edge` in the placement log must describe the anchor the layout chose, not + /// whether a margin was merely measured. These pin the three cases that previously disagreed. + func test_make_wrappedPanelAnchoredToMeasuredMarginReportsIt() { + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 700, y: 800, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 0, width: 800, height: 900), + observedContentEdges: ObservedContentEdges(leftX: 140, topY: 860) + ) + + let layout = GhostSuggestionLayout.make( + text: " wrapping text that is far too long to fit on the caret's own line", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertGreaterThan(layout.lines.count, 1) + XCTAssertTrue(layout.panelAnchoredToHostContentEdge) + } + + func test_make_singleLineAtCaretDoesNotClaimTheMeasuredMargin() { + // The margin exists, but a short suggestion anchors at the caret, which lies past it. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 200, y: 800, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 0, width: 800, height: 900), + observedContentEdges: ObservedContentEdges(leftX: 140, topY: 860) + ) + + let layout = GhostSuggestionLayout.make( + text: " hi", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertEqual(layout.lines.count, 1) + XCTAssertFalse(layout.panelAnchoredToHostContentEdge) + } + + func test_make_withoutMeasuredMarginNeverReportsIt() { + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 700, y: 800, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 0, width: 800, height: 900), + observedContentEdges: nil + ) + + let layout = GhostSuggestionLayout.make( + text: " wrapping text that is far too long to fit on the caret's own line", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 1000) + ) + + XCTAssertGreaterThan(layout.lines.count, 1) + XCTAssertFalse(layout.panelAnchoredToHostContentEdge) + } } diff --git a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift index e333420b..dbcc5053 100644 --- a/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift +++ b/CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift @@ -116,13 +116,15 @@ final class GhostFontMetricsTests: XCTestCase { XCTAssertEqual(size, 15.6, accuracy: 0.0001) } - func testSizeMultiplierScalesResolvedSize() { - // The multiplier scales the auto-approximated 15.6 in both directions. + func testSizeMultiplierScalesResolvedSizeBetweenTheBounds() { + // Inside [minimum, maximum] the multiplier scales the auto-approximated 15.6 in both + // directions. The floor is lowered to 10 so the 0.7x result (10.92) stays within it; at the + // fixture's 14pt floor it would clamp, which is the absolute-bound behavior pinned below. let smaller = GhostFontMetrics.pointSize( caretHeight: 20, fieldMetrics: nil, fallbackRatio: fallbackRatio, - minimum: minimum, + minimum: 10, maximum: maximum, sizeMultiplier: 0.7 ) @@ -132,16 +134,17 @@ final class GhostFontMetricsTests: XCTestCase { caretHeight: 20, fieldMetrics: nil, fallbackRatio: fallbackRatio, - minimum: minimum, + minimum: 10, maximum: maximum, sizeMultiplier: 1.3 ) XCTAssertEqual(larger, 15.6 * 1.3, accuracy: 0.0001) } - func testSizeMultiplierAppliesAfterTheMinimumClamp() { - // The multiplier scales the floored auto-size (not the raw caret math), so a field auto-sizing - // to the 14 floor still shrinks: 14 * 0.8 = 11.2, which is above the absolute floor. + func testSizeMultiplierAtTheFloorStaysAtTheFloor() { + // The multiplier scales before the clamp, so the user's floor is absolute: a field already + // auto-sizing onto the 14pt floor does not shrink below it (5 * 0.78 * 0.8 = 3.12 -> 14). + // Lowering "Smallest Ghost Text" is how a user asks for smaller text than that. let size = GhostFontMetrics.pointSize( caretHeight: 5, fieldMetrics: nil, @@ -150,17 +153,17 @@ final class GhostFontMetricsTests: XCTestCase { maximum: maximum, sizeMultiplier: 0.8 ) - XCTAssertEqual(size, minimum * 0.8, accuracy: 0.0001) + XCTAssertEqual(size, minimum, accuracy: 0.0001) } func testSizeMultiplierRespectsAbsoluteFloor() { - // A degenerate multiplier far below the shipped range cannot push ghost text under the - // legibility floor: 14 * 0.5 = 7, clamped up to absoluteMinimumPointSize. + // `absoluteMinimumPointSize` is the backstop beneath the user's floor, so it only binds when + // that floor is set below it. With a floor of 1: 5 * 0.78 * 0.5 = 1.95, clamped up to 9. let size = GhostFontMetrics.pointSize( caretHeight: 5, fieldMetrics: nil, fallbackRatio: fallbackRatio, - minimum: minimum, + minimum: 1, maximum: maximum, sizeMultiplier: 0.5 ) From 821afaf1e3593a89ad6e861100734991d41d8272 Mon Sep 17 00:00:00 2001 From: rp3099 <44932246+rp3099@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:18:19 -0400 Subject: [PATCH 6/6] Keep the line-margin cache key stable inside long paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a paragraph starts before the bounded text window, its real start is unknowable from the window, and c0751ef fell back to keying on the window's document origin. `nativeTextWindow` keeps a fixed number of units before the caret, so that origin advances with every character typed: inside a paragraph longer than the window, every keystroke missed the cache and issued the three blocking AX line-geometry calls on the typing path — the stall the capability gate exists to prevent. The origin is now bucketed by the window size, so the key changes at most once per window of typing. Buckets cannot merge two different such paragraphs: a caret whose paragraph start is out of view sits more than one window past that start, which lies past any earlier paragraph, so two such carets' origins always differ by more than a bucket. Distinct `p`/`u` prefixes keep known-start and bucketed keys from colliding. Skipping the lookup in that case was the alternative, but it would restore the original misaligned-margin bug in exactly the long Word paragraphs the lookup exists for. The rule is extracted as a pure, `nonisolated` static function with tests covering a visible start, a document-start window, stability while typing through a long paragraph, the once-per-window boundary, and two long paragraphs never sharing a key. Co-Authored-By: Claude Opus 5 --- .../Resolution/FocusSnapshotResolver.swift | 80 +++++++++++++------ .../FocusSnapshotResolverSelectionTests.swift | 76 ++++++++++++++++++ 2 files changed, 131 insertions(+), 25 deletions(-) diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index f23eb484..285df5ae 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -832,34 +832,16 @@ struct FocusSnapshotResolver { let observedContentEdges = caretResult?.observedContentEdges ?? (lineQueryOffsetIsDocumentRelative ? selectionForGeometry : nil).flatMap { geometrySelection -> ObservedContentEdges? in - // Keyed by the document offset where the caret's paragraph starts, alongside the focus - // session. The measured edge belongs to one visual line, and moving between paragraphs - // inside the same field — an indented block, a list item, a table cell — changes the - // margin without changing `focusChangeSequence`, which only turns over when the field's - // frame does. Finding the paragraph start is a local string scan, so no AX round trip. - // - // The offsets must agree on units. `textValue` can be a bounded window around the caret, - // indexed by the window-relative `selection`, while `geometrySelection` is document- - // relative. Measuring the window against the document offset overshoots it and lets - // different paragraphs share a key, so the paragraph start is found in window coordinates - // and shifted by the window's document origin. If the paragraph begins before the window, - // the key falls back to that origin, which moves as the window slides: a cache miss and a - // fresh lookup, never another paragraph's edge. + // Cached per paragraph as well as per focus session. `lineContentEdgesParagraphKey` + // documents how the key stays correct across paragraphs yet stable while typing. guard let windowSelection = selection, let windowText = textValue else { return nil } - let window = windowText as NSString - let caretInWindow = min(max(windowSelection.location, 0), window.length) - let newlineBeforeCaret = window.rangeOfCharacter( - from: .newlines, - options: .backwards, - range: NSRange(location: 0, length: caretInWindow) + let paragraphKey = Self.lineContentEdgesParagraphKey( + windowText: windowText, + windowCaretLocation: windowSelection.location, + documentCaretLocation: geometrySelection.location ) - let paragraphStartInWindow = newlineBeforeCaret.location == NSNotFound - ? 0 - : NSMaxRange(newlineBeforeCaret) - let windowDocumentOrigin = geometrySelection.location - windowSelection.location - let paragraphDocumentStart = windowDocumentOrigin + paragraphStartInWindow return lineContentEdgesCache.value( - forKey: "lineEdges:\(AXHelper.elementIdentity(for: element)):p\(paragraphDocumentStart)", + forKey: "lineEdges:\(AXHelper.elementIdentity(for: element)):\(paragraphKey)", focusChangeSequence: focusChangeSequence ) { geometryResolver.resolveLineContentEdges( @@ -922,6 +904,54 @@ struct FocusSnapshotResolver { ) } + /// Builds the paragraph component of the line-content-edge cache key. + /// + /// A measured margin belongs to one paragraph: moving between an indented block, a list item or + /// a table cell inside one field changes it without `focusChangeSequence` turning over. So the key + /// names the paragraph by the document offset where it starts, found with a local string scan + /// rather than an AX round trip. `windowText` is the bounded text around the caret, indexed by + /// the window-relative `windowCaretLocation`; `documentCaretLocation` is the same caret in + /// document coordinates, and their difference is the window's document origin. + /// + /// The hard case is a paragraph that starts before the window. Its real start is unknowable here, + /// and the window's own origin is not a usable stand-in: `nativeTextWindow` keeps + /// `focusedTextContextWindowUTF16` units before the caret, so the origin advances with every + /// character typed, and a key built from it would miss the cache on every keystroke — putting + /// three blocking AX calls back on the typing path. Instead the origin is bucketed by that same + /// window size, so the key changes at most once per window's worth of typing. Buckets cannot merge + /// two different such paragraphs: a caret whose paragraph start is out of view sits more than one + /// window past that start, which is itself past any earlier paragraph, so two such carets' + /// origins always differ by more than a bucket. The `p` and `u` prefixes keep the two key kinds + /// from ever colliding. + /// + /// Internal (not private) so the key rule is unit-testable without live AX elements, and + /// `nonisolated` because it is pure string arithmetic over a `Sendable` constant: inheriting the + /// resolver's `@MainActor` isolation would force every caller onto the main actor for no reason. + nonisolated static func lineContentEdgesParagraphKey( + windowText: String, + windowCaretLocation: Int, + documentCaretLocation: Int + ) -> String { + let window = windowText as NSString + let caretInWindow = min(max(windowCaretLocation, 0), window.length) + let windowDocumentOrigin = max(documentCaretLocation - caretInWindow, 0) + let newlineBeforeCaret = window.rangeOfCharacter( + from: .newlines, + options: .backwards, + range: NSRange(location: 0, length: caretInWindow) + ) + + if newlineBeforeCaret.location != NSNotFound { + return "p\(windowDocumentOrigin + NSMaxRange(newlineBeforeCaret))" + } + // No newline before the caret and the window begins at the document start, so the paragraph + // provably starts at offset 0. + if windowDocumentOrigin == 0 { + return "p0" + } + return "u\(windowDocumentOrigin / focusedTextContextWindowUTF16)" + } + /// Reads the smallest native text window the host can provide around the current selection. /// /// `AXStringForRange` is the important fast path for large Chrome and WebKit fields: instead of diff --git a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverSelectionTests.swift b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverSelectionTests.swift index 6ffa4968..2fca87da 100644 --- a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverSelectionTests.swift +++ b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverSelectionTests.swift @@ -135,4 +135,80 @@ final class FocusSnapshotResolverSelectionTests: XCTestCase { XCTAssertEqual(selected.quality, .estimated) XCTAssertEqual(selected.source, "unknown primary-fallback") } + + // MARK: - Line-content-edge paragraph key + + /// Builds the key from the same before-caret window `nativeTextWindow` produces: at most + /// `focusedTextContextWindowUTF16` units ending at the caret. Text after the caret never affects + /// the key, so it is omitted. + private func paragraphKey(document: String, caret: Int) -> String { + let doc = document as NSString + let before = min(caret, FocusSnapshotResolver.focusedTextContextWindowUTF16) + let window = doc.substring(with: NSRange(location: caret - before, length: before)) + return FocusSnapshotResolver.lineContentEdgesParagraphKey( + windowText: window, + windowCaretLocation: before, + documentCaretLocation: caret + ) + } + + private let window = FocusSnapshotResolver.focusedTextContextWindowUTF16 + + func testParagraphKeyUsesVisibleParagraphStartInDocumentCoordinates() { + // The window [3904, 8000) contains the newline at 6000, so the paragraph starts at 6001 in + // document coordinates even though the window itself starts at 3904. + let document = String(repeating: "a", count: 6000) + "\n" + String(repeating: "b", count: 3000) + XCTAssertEqual(paragraphKey(document: document, caret: 8000), "p6001") + } + + func testParagraphKeyStaysStableWhileTypingWithAVisibleParagraphStart() { + // The window slides with the caret, but origin + position-in-window stays constant. + let document = String(repeating: "a", count: 6000) + "\n" + String(repeating: "b", count: 3000) + let keys = Set((7000..<7300).map { paragraphKey(document: document, caret: $0) }) + XCTAssertEqual(keys, ["p6001"]) + } + + func testParagraphKeyIsZeroWhenTheWindowStartsAtTheDocumentStart() { + XCTAssertEqual(paragraphKey(document: "hello world", caret: 11), "p0") + } + + /// The regression this key exists to prevent. With the paragraph start out of view, the previous + /// rule keyed on the window's document origin, which advances with every character typed: 1,000 + /// keystrokes produced 1,000 distinct keys, and every one missed the cache and issued three + /// blocking AX calls on the typing path. Bucketing the origin keeps the key fixed. + func testParagraphKeyStaysStableWhileTypingThroughAParagraphLongerThanTheWindow() { + let document = String(repeating: "a", count: 60_000) + let start = 11 * window + let keys = Set((start..<(start + 1000)).map { paragraphKey(document: document, caret: $0) }) + + XCTAssertEqual(keys.count, 1) + XCTAssertTrue(keys.first?.hasPrefix("u") == true) + } + + func testParagraphKeyChangesAtMostOncePerWindowOfTyping() { + let document = String(repeating: "a", count: 60_000) + // Origins 11*window - 1 and 11*window straddle a bucket boundary... + XCTAssertNotEqual( + paragraphKey(document: document, caret: 12 * window - 1), + paragraphKey(document: document, caret: 12 * window) + ) + // ...and then the key holds for a full window of typing. + XCTAssertEqual( + paragraphKey(document: document, caret: 12 * window), + paragraphKey(document: document, caret: 13 * window - 1) + ) + } + + func testParagraphKeyNeverMergesTwoParagraphsLongerThanTheWindow() { + // Both carets have their paragraph start out of view. The second caret sits more than one + // window past its paragraph's start, which lies past the first caret, so the two window + // origins differ by more than a bucket and the keys cannot coincide. + let document = String(repeating: "a", count: 30_000) + "\n" + String(repeating: "b", count: 30_000) + let endOfFirst = paragraphKey(document: document, caret: 30_000) + let justPastViewInSecond = paragraphKey(document: document, caret: 30_001 + window + 1) + + XCTAssertTrue(endOfFirst.hasPrefix("u")) + XCTAssertTrue(justPastViewInSecond.hasPrefix("u")) + XCTAssertNotEqual(endOfFirst, justPastViewInSecond) + } }