-
-
Notifications
You must be signed in to change notification settings - Fork 39
feat(context): make the live preview a real field the app completes in #655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import Foundation | ||
|
|
||
| /// File overview: | ||
| /// The pure decision behind Cotabby's "never complete inside our own UI" rule and its single | ||
| /// sanctioned exception. It is kept free of Accessibility objects and timer state so the invariant | ||
| /// that matters most here can be unit-tested directly: completions must never leak into Cotabby's own | ||
| /// settings surfaces (search field, Extended Context editor, menus) except the one live-preview box. | ||
| /// | ||
| /// `FocusTracker` owns the AX reads; it hands the focused element's bundle and identifier to this type | ||
| /// and lets it answer yes/no. | ||
| enum SelfCaptureGate { | ||
| /// Whether the focus pipeline may capture the currently focused element. | ||
| /// | ||
| /// Apps other than Cotabby are always allowed: this rule only constrains capturing Cotabby's own | ||
| /// UI. When Cotabby itself is focused, capture is allowed only for the sanctioned element (the | ||
| /// Context pane's live-preview field, matched by AX identifier) and blocked for everything else. | ||
| /// | ||
| /// Fails closed: with no sanctioned identifier configured, or an element whose identifier cannot | ||
| /// be read, self-capture stays blocked. `focusedElementIdentifier` is an `@autoclosure` so the AX | ||
| /// read it usually wraps is skipped entirely for every other app (the common path, run on every | ||
| /// poll tick). | ||
| static func allowsCapture( | ||
| focusedBundleIdentifier: String?, | ||
| ignoredBundleIdentifier: String?, | ||
| focusedElementIdentifier: @autoclosure () -> String?, | ||
| sanctionedElementIdentifier: String? | ||
| ) -> Bool { | ||
| // Not our own process: untouched by this rule. | ||
| guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true } | ||
| // Our own process: allow only the one sanctioned element. | ||
| guard let sanctioned = sanctionedElementIdentifier else { return false } | ||
| return focusedElementIdentifier() == sanctioned | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||||||||||||||
| import AppKit | ||||||||||||||||||||
| import SwiftUI | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /// File overview: | ||||||||||||||||||||
| /// The Context pane's live-preview field, and the shared identity that lets the real pipeline drive it. | ||||||||||||||||||||
| /// | ||||||||||||||||||||
| /// Cotabby never completes inside its own UI (`FocusTracker` blocks capture whenever Cotabby is the | ||||||||||||||||||||
| /// focused app, so completions can never appear in the settings search field, the Extended Context | ||||||||||||||||||||
| /// editor, menus, and so on). The live preview is the single sanctioned exception: it is a real, | ||||||||||||||||||||
| /// native editable text view, tagged with `ContextLivePreview.accessibilityIdentifier`, and | ||||||||||||||||||||
| /// `FocusTracker` lifts its self-capture rule for that one element only. Typing in it is therefore a | ||||||||||||||||||||
| /// genuine end-to-end exercise of the production focus -> suggestion -> overlay -> insertion path, the | ||||||||||||||||||||
| /// same one every other app gets. | ||||||||||||||||||||
| /// | ||||||||||||||||||||
| /// Because the real overlay renders the gray suggestion at the caret and the real inserter commits it | ||||||||||||||||||||
| /// on the accept key, this view renders nothing itself. That is the whole point of the redesign: the | ||||||||||||||||||||
| /// previous in-app editor mirrored a SwiftUI binding into an `NSTextView` and reconciled a ghost run | ||||||||||||||||||||
| /// inside the editable storage, and that reconciliation raced with live keystrokes and corrupted typed | ||||||||||||||||||||
| /// text. Here the `NSTextView` owns its string outright and nothing reaches into it, so typing is fully | ||||||||||||||||||||
| /// native. | ||||||||||||||||||||
| enum ContextLivePreview { | ||||||||||||||||||||
| /// AX identifier on the preview field. `FocusTracker` keys on this exact value to allow | ||||||||||||||||||||
| /// self-capture for this element and nothing else in Cotabby's own windows. Wired into the focus | ||||||||||||||||||||
| /// pipeline in `CotabbyAppEnvironment`. | ||||||||||||||||||||
| static let accessibilityIdentifier = "com.cotabby.settings.context.live-preview" | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /// A plain multi-line `NSTextView` whose only special behavior is carrying the sanctioned AX | ||||||||||||||||||||
| /// identifier. No ghost logic, no binding round-trip: the running app completes in it like any field. | ||||||||||||||||||||
| struct ContextLivePreviewField: NSViewRepresentable { | ||||||||||||||||||||
| private static let fontSize: CGFloat = 13 | ||||||||||||||||||||
| private static let textInset = NSSize(width: 8, height: 8) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func makeNSView(context: Context) -> NSScrollView { | ||||||||||||||||||||
| let textView = NSTextView() | ||||||||||||||||||||
| textView.font = .monospacedSystemFont(ofSize: Self.fontSize, weight: .regular) | ||||||||||||||||||||
| textView.textColor = .labelColor | ||||||||||||||||||||
| textView.isRichText = false | ||||||||||||||||||||
| // Keep typing literal so the preview shows exactly what the model receives: no curly quotes, | ||||||||||||||||||||
| // dash collapsing, autocorrect, or text replacement rewriting the user's characters. | ||||||||||||||||||||
| textView.isAutomaticQuoteSubstitutionEnabled = false | ||||||||||||||||||||
| textView.isAutomaticDashSubstitutionEnabled = false | ||||||||||||||||||||
| textView.isAutomaticSpellingCorrectionEnabled = false | ||||||||||||||||||||
| textView.isAutomaticTextReplacementEnabled = false | ||||||||||||||||||||
| textView.isContinuousSpellCheckingEnabled = false | ||||||||||||||||||||
| textView.allowsUndo = true | ||||||||||||||||||||
| textView.textContainerInset = Self.textInset | ||||||||||||||||||||
| textView.drawsBackground = false | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // The identifier is the entire contract with `FocusTracker`: it is how the focus poller tells | ||||||||||||||||||||
| // this sanctioned field apart from every other element in Cotabby's own windows. | ||||||||||||||||||||
| textView.setAccessibilityIdentifier(ContextLivePreview.accessibilityIdentifier) | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The previous
Suggested change
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! |
||||||||||||||||||||
|
|
||||||||||||||||||||
| // Standard incantation for a wrapping, vertically-growing text view inside a scroll view. | ||||||||||||||||||||
| textView.minSize = NSSize(width: 0, height: 0) | ||||||||||||||||||||
| textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) | ||||||||||||||||||||
| textView.isVerticallyResizable = true | ||||||||||||||||||||
| textView.isHorizontallyResizable = false | ||||||||||||||||||||
| textView.autoresizingMask = [.width] | ||||||||||||||||||||
| textView.textContainer?.widthTracksTextView = true | ||||||||||||||||||||
| textView.textContainer?.containerSize = NSSize( | ||||||||||||||||||||
| width: 0, | ||||||||||||||||||||
| height: CGFloat.greatestFiniteMagnitude | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let scrollView = NSScrollView() | ||||||||||||||||||||
| scrollView.documentView = textView | ||||||||||||||||||||
| scrollView.hasVerticalScroller = true | ||||||||||||||||||||
| scrollView.drawsBackground = false | ||||||||||||||||||||
| scrollView.borderType = .noBorder | ||||||||||||||||||||
| return scrollView | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func updateNSView(_ scrollView: NSScrollView, context: Context) { | ||||||||||||||||||||
| // Nothing to reconcile: the text view owns its content and the real pipeline owns the | ||||||||||||||||||||
| // suggestion. Intentionally empty. | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nil == nilsilently classifies bundle-less apps as CotabbySwift's
Optional<String>equality makesnil == nilreturntrue, so ifignoredBundleIdentifieris evernil(e.g., during unit tests that omit it, or ifBundle.main.bundleIdentifieris somehow absent), any focused process that also has no bundle ID passes the guard and falls into the "our own process" branch. There it needs the AX identifier to matchsanctionedElementIdentifier— which it won't — so completions are silently blocked in those apps. A fast-fail guard on both being non-nil keeps the intent clear and makes the behavior explicit.