From cd91f6a083b9c2b3c3fab8416629adbf34bc7270 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 28 May 2026 02:16:44 -0700 Subject: [PATCH] Let users toggle popup-card suggestion display from Settings and the menu Phase 1 only triggered the new render mode when caret geometry was estimated. This adds a persistent global preference (Auto / Inline / Popup) so users can pin one style for every app, and surfaces it both in the Settings General section and in the menu bar pop-up so it's reachable without opening Settings. The setting flows through `SuggestionSettingsSnapshot.mirrorPreference` and the overlay controller reads it live each presentation, so the toggle takes effect on the very next completion without a restart. Defaults remain `.auto`, so existing users see identical behavior. --- Cotabby/Models/SuggestionEngineModels.swift | 4 ++ Cotabby/Models/SuggestionSettingsModel.swift | 41 ++++++++++++++++--- Cotabby/Services/UI/OverlayController.swift | 24 +++++++++-- .../Support/CompletionRenderModePolicy.swift | 28 ++++++++++--- Cotabby/UI/MenuBarView.swift | 22 ++++++++++ Cotabby/UI/SettingsView.swift | 19 +++++++++ CotabbyTests/CotabbyTestFixtures.swift | 6 ++- ...SuggestionAvailabilityEvaluatorTests.swift | 38 +++++++++++++++++ 8 files changed, 165 insertions(+), 17 deletions(-) diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index 79cbb28..3681fbc 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -72,4 +72,8 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// When true, the screenshot/OCR visual-context pipeline is skipped entirely for lower-latency /// suggestions. Defaults to false. Only affects visual context — predictions still run. let isFastModeEnabled: Bool + /// User preference for how suggestions are presented (inline ghost text vs popup card vs auto + /// based on caret geometry quality). Travels in the snapshot so consumers can react to changes + /// without subscribing to the settings model directly. + let mirrorPreference: MirrorPreference } diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 3f4c8bd..573a5b8 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -23,6 +23,8 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var selectedWordCountPreset: SuggestionWordCountPreset @Published private(set) var isClipboardContextEnabled: Bool @Published private(set) var isFastModeEnabled: Bool + /// How suggestions are presented (inline ghost text vs popup card vs auto). + @Published private(set) var mirrorPreference: MirrorPreference @Published private(set) var userName: String @Published private(set) var customRules: [String] @Published private(set) var responseLanguages: [String] @@ -49,6 +51,7 @@ final class SuggestionSettingsModel: ObservableObject { private static let selectedWordCountPresetDefaultsKey = "cotabbySelectedWordCountPreset" private static let clipboardContextEnabledDefaultsKey = "cotabbyClipboardContextEnabled" private static let fastModeEnabledDefaultsKey = "cotabbyFastModeEnabled" + private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference" private static let userNameDefaultsKey = "cotabbyUserName" private static let customRulesDefaultsKey = "cotabbyCustomRules" private static let responseLanguagesDefaultsKey = "cotabbyResponseLanguages" @@ -121,6 +124,13 @@ final class SuggestionSettingsModel: ObservableObject { // into fast mode turns it off. let resolvedFastModeEnabled = userDefaults.object(forKey: Self.fastModeEnabledDefaultsKey) as? Bool ?? false + // Default `.auto` keeps existing users on the byte-for-byte original inline rendering for + // hosts that report exact/derived caret geometry; only `.estimated` hosts see the new popup + // card. Power users can pin one mode from Settings or the menu bar. + let resolvedMirrorPreference = userDefaults + .string(forKey: Self.mirrorPreferenceDefaultsKey) + .flatMap(MirrorPreference.init(rawValue:)) + ?? .auto let resolvedUserName: String = if userDefaults.object(forKey: Self.userNameDefaultsKey) == nil { configuration.defaultUserName ?? "" } else { @@ -201,6 +211,7 @@ final class SuggestionSettingsModel: ObservableObject { selectedWordCountPreset = resolvedWordCountPreset isClipboardContextEnabled = resolvedClipboardContextEnabled isFastModeEnabled = resolvedFastModeEnabled + mirrorPreference = resolvedMirrorPreference userName = resolvedUserName customRules = resolvedCustomRules responseLanguages = resolvedResponseLanguages @@ -225,6 +236,7 @@ final class SuggestionSettingsModel: ObservableObject { persistSelectedWordCountPreset(resolvedWordCountPreset) persistClipboardContextEnabled(resolvedClipboardContextEnabled) persistFastModeEnabled(resolvedFastModeEnabled) + persistMirrorPreference(resolvedMirrorPreference) persistUserName(resolvedUserName) persistCustomRules(resolvedCustomRules) persistResponseLanguages(resolvedResponseLanguages) @@ -266,7 +278,8 @@ final class SuggestionSettingsModel: ObservableObject { focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, - isFastModeEnabled: isFastModeEnabled + isFastModeEnabled: isFastModeEnabled, + mirrorPreference: mirrorPreference ) } @@ -306,6 +319,15 @@ final class SuggestionSettingsModel: ObservableObject { persistFastModeEnabled(enabled) } + func setMirrorPreference(_ preference: MirrorPreference) { + guard mirrorPreference != preference else { + return + } + + mirrorPreference = preference + persistMirrorPreference(preference) + } + func setMultiLineEnabled(_ enabled: Bool) { guard isMultiLineEnabled != enabled else { return @@ -619,6 +641,10 @@ final class SuggestionSettingsModel: ObservableObject { userDefaults.set(enabled, forKey: Self.fastModeEnabledDefaultsKey) } + private func persistMirrorPreference(_ preference: MirrorPreference) { + userDefaults.set(preference.rawValue, forKey: Self.mirrorPreferenceDefaultsKey) + } + private func persistShowIndicator(_ show: Bool) { let mode: ActivationIndicatorMode = show ? .fieldEdgeIcon : .hidden userDefaults.set(mode.rawValue, forKey: Self.selectedIndicatorModeDefaultsKey) @@ -748,6 +774,10 @@ final class SuggestionSettingsModel: ObservableObject { extension SuggestionSettingsModel: SuggestionSettingsProviding { var snapshotPublisher: AnyPublisher { + // The publisher count creeps up as we add settings, but Combine caps each operator at 4 + // upstreams. Group related settings into nested combiners so the shape stays readable. + // `presentationToggles` carries the visual-pipeline knobs (clipboard, fast mode, mirror + // preference); they share the property of "affects how/when suggestions are shown". Publishers.CombineLatest4( Publishers.CombineLatest4( $isGloballyEnabled, @@ -755,7 +785,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $selectedEngine, $selectedWordCountPreset ), - Publishers.CombineLatest($isClipboardContextEnabled, $isFastModeEnabled), + Publishers.CombineLatest3($isClipboardContextEnabled, $isFastModeEnabled, $mirrorPreference), Publishers.CombineLatest3($userName, $customRules, $responseLanguages), Publishers.CombineLatest4( $debounceMilliseconds, @@ -764,9 +794,9 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $autoAcceptTrailingPunctuation ) ) - .map { combinedSettings, contextToggles, profile, timing in + .map { combinedSettings, presentationToggles, profile, timing in let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings - let (clipboardContextEnabled, fastModeEnabled) = contextToggles + let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles let (userName, customRules, responseLanguages) = profile let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing return SuggestionSettingsSnapshot( @@ -782,7 +812,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { focusPollIntervalMilliseconds: focusPoll, isMultiLineEnabled: multiLine, autoAcceptTrailingPunctuation: autoAcceptPunctuation, - isFastModeEnabled: fastModeEnabled + isFastModeEnabled: fastModeEnabled, + mirrorPreference: mirrorPreference ) } .removeDuplicates() diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index d5546cd..75e74c6 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -20,13 +20,29 @@ final class OverlayController: SuggestionOverlayControlling { var onStateChange: ((OverlayState) -> Void)? private let suggestionSettings: SuggestionSettingsModel - private let renderModePolicy: CompletionRenderModePolicy + + /// Optional injection seam for tests. When set, `currentRenderModePolicy` returns this directly + /// instead of building one from live settings. Production code leaves this nil. + private let renderModePolicyOverride: CompletionRenderModePolicy? /// Bundle identifier of the currently focused host app, supplied by the coordinator each time /// a suggestion is presented. The policy uses this to look up per-app overrides. Nil in tests /// or when the focus pipeline could not identify the host. private var currentBundleIdentifier: String? + /// Built from the live `mirrorPreference` setting at call time rather than cached. The struct + /// is tiny (one enum + an empty dict in Phase 2) so per-show allocation cost is negligible, + /// and the read-through model means the user's Settings/menu-bar toggle takes effect on the + /// very next presentation without any subscription bookkeeping. + private var currentRenderModePolicy: CompletionRenderModePolicy { + if let renderModePolicyOverride { + return renderModePolicyOverride + } + return CompletionRenderModePolicy( + userPreference: suggestionSettings.mirrorPreference + ) + } + private(set) var state: OverlayState = .hidden(reason: "Overlay idle.") { didSet { onStateChange?(state) @@ -50,10 +66,10 @@ final class OverlayController: SuggestionOverlayControlling { init( suggestionSettings: SuggestionSettingsModel, - renderModePolicy: CompletionRenderModePolicy = CompletionRenderModePolicy() + renderModePolicyOverride: CompletionRenderModePolicy? = nil ) { self.suggestionSettings = suggestionSettings - self.renderModePolicy = renderModePolicy + self.renderModePolicyOverride = renderModePolicyOverride } /// Coordinator hook that updates the bundle identifier used by per-app overrides. Phase 1 @@ -95,7 +111,7 @@ final class OverlayController: SuggestionOverlayControlling { return } - let mode = renderModePolicy.mode( + let mode = currentRenderModePolicy.mode( for: geometry, bundleIdentifier: currentBundleIdentifier ) diff --git a/Cotabby/Support/CompletionRenderModePolicy.swift b/Cotabby/Support/CompletionRenderModePolicy.swift index 6272404..6cc77f0 100644 --- a/Cotabby/Support/CompletionRenderModePolicy.swift +++ b/Cotabby/Support/CompletionRenderModePolicy.swift @@ -7,21 +7,37 @@ import Foundation /// auto rule misfires for their host mix. /// /// Phase 1 hardcodes `.auto` everywhere; the global setting and per-app overrides land in Phase 2. -enum MirrorPreference: String, Codable, CaseIterable, Equatable, Sendable { +enum MirrorPreference: String, Codable, CaseIterable, Identifiable, Equatable, Sendable { case auto case alwaysInline case alwaysMirror - /// Human-readable label for Settings UI. Kept here so Settings code does not have to repeat the - /// mapping; the policy is the single source of truth for both the rule and the copy. + var id: String { rawValue } + + /// Human-readable label for Settings UI and the menu bar pop-up. Kept here so the UI code does + /// not have to repeat the mapping; the policy is the single source of truth for both the rule + /// and the copy. Phrased in user-facing terms ("popup") rather than the internal "mirror" name. var displayLabel: String { switch self { case .auto: - return "Auto (recommended)" + return "Auto" + case .alwaysInline: + return "Inline" + case .alwaysMirror: + return "Popup" + } + } + + /// One-sentence explanation suitable for `.help()` tooltips next to the picker. Read as a group + /// the three help strings teach the user when each option is the right pick. + var helpDescription: String { + switch self { + case .auto: + return "Inline ghost text when caret position is reliable; popup card when it isn't (some Electron and web editors)." case .alwaysInline: - return "Always inline" + return "Always draw ghost text next to the caret, even when caret position may drift." case .alwaysMirror: - return "Always preview card" + return "Always show suggestions in a popup card anchored below the focused field." } } } diff --git a/Cotabby/UI/MenuBarView.swift b/Cotabby/UI/MenuBarView.swift index b38da7f..0275e91 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -149,6 +149,21 @@ struct MenuBarView: View { .pickerStyle(.menu) .help("How long completions tend to be. Shorter is faster and less likely to overreach.") } + + MenuBarPickerRow(title: "Display") { + Picker("Display", selection: mirrorPreferenceBinding) { + ForEach(MirrorPreference.allCases) { preference in + Text(preference.displayLabel) + .tag(preference) + } + } + .labelsHidden() + .pickerStyle(.menu) + .help( + "Inline ghost text or a popup card. Auto switches to the popup for hosts " + + "where caret position can't be tracked reliably." + ) + } } } .padding(.bottom, 12) @@ -326,6 +341,13 @@ struct MenuBarView: View { ) } + private var mirrorPreferenceBinding: Binding { + Binding( + get: { suggestionSettings.mirrorPreference }, + set: { suggestionSettings.setMirrorPreference($0) } + ) + } + private var selectedModelBinding: Binding { Binding( get: { diff --git a/Cotabby/UI/SettingsView.swift b/Cotabby/UI/SettingsView.swift index b6758d6..aa62688 100644 --- a/Cotabby/UI/SettingsView.swift +++ b/Cotabby/UI/SettingsView.swift @@ -151,6 +151,18 @@ struct SettingsView: View { Toggle("Show Accept Hint", isOn: showAcceptanceHintBinding) .help("Show a small label near the ghost text reminding you which key accepts it.") + Picker("Suggestion Display", selection: mirrorPreferenceBinding) { + ForEach(MirrorPreference.allCases) { preference in + Text(preference.displayLabel).tag(preference) + } + } + .pickerStyle(.menu) + .help( + "Auto uses inline ghost text when the focused field exposes a reliable cursor " + + "position, and switches to a popup card when it doesn't (some Electron and web " + + "editors). Choose Inline or Popup to pin one style for every app." + ) + Toggle("Allow Multi-line Suggestions", isOn: multiLineEnabledBinding) .help("Let suggestions span more than one line. Off keeps them to a single line.") @@ -704,6 +716,13 @@ struct SettingsView: View { ) } + private var mirrorPreferenceBinding: Binding { + Binding( + get: { suggestionSettings.mirrorPreference }, + set: { suggestionSettings.setMirrorPreference($0) } + ) + } + private var showIndicatorBinding: Binding { Binding( get: { suggestionSettings.showIndicator }, diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index dc600d5..2d2d1f2 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -220,7 +220,8 @@ enum CotabbyTestFixtures { focusPollIntervalMilliseconds: Int = 50, isMultiLineEnabled: Bool = false, autoAcceptTrailingPunctuation: Bool = true, - isFastModeEnabled: Bool = false + isFastModeEnabled: Bool = false, + mirrorPreference: MirrorPreference = .auto ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -235,7 +236,8 @@ enum CotabbyTestFixtures { focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, - isFastModeEnabled: isFastModeEnabled + isFastModeEnabled: isFastModeEnabled, + mirrorPreference: mirrorPreference ) } } diff --git a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift index 146665f..626b1de 100644 --- a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift @@ -571,6 +571,44 @@ final class SuggestionSettingsModelDisabledAppsTests: XCTestCase { _ = cancellables } + func test_mirrorPreference_defaultsToAutoAndPersists() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + XCTAssertEqual(model.mirrorPreference, .auto) + XCTAssertEqual(model.snapshot.mirrorPreference, .auto) + + model.setMirrorPreference(.alwaysMirror) + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertEqual(reloadedModel.mirrorPreference, .alwaysMirror) + XCTAssertEqual(reloadedModel.snapshot.mirrorPreference, .alwaysMirror) + } + } + + func test_snapshotPublisher_emitsWhenMirrorPreferenceChanges() { + let expectation = expectation(description: "snapshot emits after mirror preference changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertEqual(snapshot.mirrorPreference, .alwaysInline) + expectation.fulfill() + } + .store(in: &cancellables) + + model.setMirrorPreference(.alwaysInline) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + func test_acceptanceHint_defaultsToOnAndShowsWordAcceptLabel() { runOnMainActor { let model = makeModel()