Trust native AX caret geometry; scope the layout-estimate override to web content#676
Merged
Conversation
… in web content Native hosts answer previous-character AXBoundsForRange from their real layout manager, so a vertical disagreement with the uniform hidden layout indicts the estimate, not AX: Notes renders a 23pt title line above 16pt body lines plus paragraph spacing, and the #670 line-mismatch override was replacing its correct caret with an estimate a full line off. A new WebContentFieldDetector classifies the focused field from DOM-reflection attributes and browser/Electron bundles (no extra AX IPC); derived geometry in native hosts now bypasses the estimator entirely, and every bypass is logged with a skip reason.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#670's caret repair gave the hidden-text-layout estimate authority to override a
.derivedAX rect whenever the two disagreed vertically by more than 0.75 line, on the theory that a mismatch means the host mapped the caret into the wrong visual line. That theory is correct for web engines' AX bridges (the Gmail drift it was built for) and wrong for native apps: Notes answers previous-characterAXBoundsForRangefrom its real layout (a 23pt title line above 16pt body lines, plus paragraph spacing), which a uniform hidden layout cannot model, so within a few lines the layout "disagrees" while AX is exactly right, and the override moved ghost text a full line off the caret. This PR scopes estimator authority by geometry provenance: a newWebContentFieldDetectorclassifies the focused field as web-rendered (DOM-reflection attributes on the resolved element, or a known browser/Electron bundle),.derivedgeometry in native hosts now bypasses the estimator unconditionally (restoring pre-#670 anchoring there), and.estimatedsubstitution stays universal because AXFrame-only hosts have no real measurement to protect.Detection adds no AX traffic (the resolver already fetches the attribute-name list it keys on), unknown hosts default to native (worst case is pre-#670 behavior, never a new failure), and the bypass now logs a
skippedoutcome with askip_reason, which also closes the silent run-measured path flagged in #670's review.Validation
Live AX probe of Notes on this machine (macOS 26), which pinned the regression mechanically before the fix was designed:
AXTextAreavends no DOM-reflection attributes and exposes no static-text child runs, so it resolves through previous-character bounds:.derivedwithout run frames, exactly the mode Estimate caret position from a hidden text layout when AX exposes only the field frame #670's override applies to.AXBoundsForRangeshows mixed line boxes (title 23pt, separator 17pt, body 16pt) that the estimator's pinned uniform line height cannot reproduce; the cumulative error crosses the 0.75-line override threshold within about three lines.AXBoundsForRangein Notes is systematically one visual line high as well as zero-width, so Branch 1's empty-rect rejection is load-bearing and was deliberately left untouched.The repair stage now emits
repair_outcome: "skipped"withskip_reason: "native_host_geometry" | "run_measured_geometry", so the trust decision is auditable perrequest_idunder-cotabby-debug.Not yet verified end-to-end in a live Notes typing session; the evidence is the probe-derived geometry plus the unit locks above.
Linked issues
Refs #670. The Notes-class regression was reported directly after #670 merged and has no separate issue filed.
Risk / rollout notes
.derivedgeometry in non-web hosts: they return to pre-Estimate caret position from a hidden text layout when AX exposes only the field frame #670 anchoring (the AX rect, rendered inline as before). Web hosts (DOM-attribute elements, browser bundles, Electron editors) keep the Estimate caret position from a hidden text layout when AX exposes only the field frame #670 override and the run-measured bypass bit-for-bit, so the Gmail/Outlook wins are unchanged.FocusedInputSnapshot/FocusedInputContextgain one immutable Bool; equality-based polling dedupe is unaffected because the flag is constant for a given focused field.project.pbxprojregenerated by XcodeGen (additions only).Greptile Summary
This PR scopes the
#670caret-layout-repair estimator to web-rendered fields only, fixing a regression where native apps like Apple Notes (which use their own layout manager for rich-text geometry) had ghost text shifted off the real caret line. A newWebContentFieldDetectorclassifies each focused field as web or native using two independent signals: DOM-reflection attributes already present in the fetched AX attribute list (no extra round-trip), and a bundle-identifier check viaBrowserAppDetector.WebContentFieldDetectorclassifies fields as web vs. native, with unknown hosts defaulting conservatively to native (worst case: pre-Estimate caret position from a hidden text layout when AX exposes only the field frame #670 behavior, never a new failure mode).layoutRepairedAnchorgating now bypasses the estimator for all.derivedgeometry in native hosts; web hosts retain the prior line-mismatch override path;.estimatedgeometry (no real measurement exists) still substitutes regardless of host type.repair_outcome: \"skipped\"with askip_reasonenum, closing the previously silent run-measured and native-host bypass paths that were unreachable from the log stream.Confidence Score: 4/5
Safe to merge; the behavioral change is isolated to
.derivedgeometry in non-web hosts, which revert to pre-#670 anchoring. Web hosts are unchanged bit-for-bit.The fix is well-scoped and mechanically verified against the Notes regression with live AX probes. The two style notes are non-blocking: a redundant nil-guard in the logging path and the reuse of
BrowserAppDetector.isBrowser(a tone-hint utility) to drive the trust policy, which is correct today but creates a latent coupling risk if that prefix list is extended for unrelated reasons in the future.WebContentFieldDetector.swiftand its use ofBrowserAppDetector.isBrowserare worth a second look to confirm the coupling is acceptable long-term; all other files are straightforward propagation or test additions.Important Files Changed
isWebContentFielddelegates toBrowserAppDetector.isBrowser, which was designed for a different purpose and could silently widen the trust policy if its prefix list is extended for tone-hint reasons..derivedgeometry now bypasses the estimator unconditionally, web.derivedgeometry retains the prior line-mismatch gate,.estimatedpath is unchanged. ThelogLayoutRepairDiagnosticupdate has a redundantif let skipReasoncheck that's always non-nil at that branch. Removal ofkept_ax_run_measuredlog value is correct (that path was previously unreachable).vendsDOMAttributesthroughAXFocusCandidateto snapshot assembly without adding any new AX round-trips; the attribute name list is already fetched earlier in the same candidate probe.isWebContentField: Boolwith afalsedefault that keeps all existing call sites compiling unchanged; participates inEquatablesynthesis harmlessly since the flag is constant for a given focused element.isWebContentFieldfromFocusedInputSnapshotintoFocusedInputContextcorrectly.isWebContentField: trueto existing derived-geometry tests (preserving their intent) and new native-host tests locking the Notes-class regression and the native-estimated-still-substitutes invariant.isWebContentField: Bool = falseto both fixture builders; default value preserves existing test behavior.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[layoutRepairedAnchor called] --> B{quality == .estimated or .derived?} B -- No --> C[Return: rect as-is, outcome: nil] B -- Yes --> D{quality == .derived?} D -- No .estimated --> E[Run layout estimator] D -- Yes --> F{isWebContentField?} F -- No native host --> G[Return: AX rect, skipReason: nativeHostGeometry] F -- Yes web host --> H{observedContentEdges != nil?} H -- Yes --> I[Return: AX rect, skipReason: runMeasuredGeometry] H -- No --> E E --> J{Estimator result} J -- .rejected --> K[Return: fallback rect] J -- .estimate --> L{verticallyAgrees?} L -- Yes --> M[Return: AX rect, log: kept_ax_agreement] L -- No --> N[Return: estimate rect, quality: .layoutEstimated]Comments Outside Diff (2)
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift, line 809-822 (link)skipReasonThe outer
guard anchor.outcome != nil || anchor.skipReason != nil else { return }ensures that when execution reaches this innerguard let outcomeelse-branch (i.e.,outcome == nil),skipReasonis guaranteed non-nil. Theif let skipReasonnested inside will always bind, which can mislead a future reader into thinking there is a reachable code path whereskipReasoncould benilat that point. A force-unwrap with a comment, or restructuring aslet skipReason = anchor.skipReason!, would make the invariant explicit.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Cotabby/Support/WebContentFieldDetector.swift, line 397-406 (link)isBrowserrepurposed outside its documented scopeBrowserAppDetector.isBrowseris documented as the "broad 'is the user typing in a web browser' tone hint," and its prefix list is maintained with that purpose in mind.WebContentFieldDetectornow gives it a second role: deciding whether AX-derived geometry may be overridden by the layout estimator. If a future contributor adds a non-web-engine bundle prefix tobrowserBundlePrefixesfor tone-hint breadth (e.g. an Electron email client that shouldn't expose its AX geometry to the estimator), the trust policy would silently widen. A narrowly scopedisWebEngine(bundleIdentifier:)method onBrowserAppDetector, or a shared private constant the two detectors both reference, would make the coupling explicit and enforce the distinction at the call-site.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Reviews (1): Last reviewed commit: "fix(caret): only let the layout estimate..." | Re-trigger Greptile