Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
41 changes: 36 additions & 5 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -201,6 +211,7 @@ final class SuggestionSettingsModel: ObservableObject {
selectedWordCountPreset = resolvedWordCountPreset
isClipboardContextEnabled = resolvedClipboardContextEnabled
isFastModeEnabled = resolvedFastModeEnabled
mirrorPreference = resolvedMirrorPreference
userName = resolvedUserName
customRules = resolvedCustomRules
responseLanguages = resolvedResponseLanguages
Expand All @@ -225,6 +236,7 @@ final class SuggestionSettingsModel: ObservableObject {
persistSelectedWordCountPreset(resolvedWordCountPreset)
persistClipboardContextEnabled(resolvedClipboardContextEnabled)
persistFastModeEnabled(resolvedFastModeEnabled)
persistMirrorPreference(resolvedMirrorPreference)
persistUserName(resolvedUserName)
persistCustomRules(resolvedCustomRules)
persistResponseLanguages(resolvedResponseLanguages)
Expand Down Expand Up @@ -266,7 +278,8 @@ final class SuggestionSettingsModel: ObservableObject {
focusPollIntervalMilliseconds: focusPollIntervalMilliseconds,
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
isFastModeEnabled: isFastModeEnabled
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -748,14 +774,18 @@ final class SuggestionSettingsModel: ObservableObject {

extension SuggestionSettingsModel: SuggestionSettingsProviding {
var snapshotPublisher: AnyPublisher<SuggestionSettingsSnapshot, Never> {
// 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,
$disabledAppRules,
$selectedEngine,
$selectedWordCountPreset
),
Publishers.CombineLatest($isClipboardContextEnabled, $isFastModeEnabled),
Publishers.CombineLatest3($isClipboardContextEnabled, $isFastModeEnabled, $mirrorPreference),
Publishers.CombineLatest3($userName, $customRules, $responseLanguages),
Publishers.CombineLatest4(
$debounceMilliseconds,
Expand All @@ -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(
Expand All @@ -782,7 +812,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
focusPollIntervalMilliseconds: focusPoll,
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
isFastModeEnabled: fastModeEnabled
isFastModeEnabled: fastModeEnabled,
mirrorPreference: mirrorPreference
)
}
.removeDuplicates()
Expand Down
24 changes: 20 additions & 4 deletions Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -95,7 +111,7 @@ final class OverlayController: SuggestionOverlayControlling {
return
}

let mode = renderModePolicy.mode(
let mode = currentRenderModePolicy.mode(
for: geometry,
bundleIdentifier: currentBundleIdentifier
)
Expand Down
28 changes: 22 additions & 6 deletions Cotabby/Support/CompletionRenderModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
22 changes: 22 additions & 0 deletions Cotabby/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -326,6 +341,13 @@ struct MenuBarView: View {
)
}

private var mirrorPreferenceBinding: Binding<MirrorPreference> {
Binding(
get: { suggestionSettings.mirrorPreference },
set: { suggestionSettings.setMirrorPreference($0) }
)
}

private var selectedModelBinding: Binding<String> {
Binding(
get: {
Expand Down
19 changes: 19 additions & 0 deletions Cotabby/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down Expand Up @@ -704,6 +716,13 @@ struct SettingsView: View {
)
}

private var mirrorPreferenceBinding: Binding<MirrorPreference> {
Binding(
get: { suggestionSettings.mirrorPreference },
set: { suggestionSettings.setMirrorPreference($0) }
)
}

private var showIndicatorBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.showIndicator },
Expand Down
6 changes: 4 additions & 2 deletions CotabbyTests/CotabbyTestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -235,7 +236,8 @@ enum CotabbyTestFixtures {
focusPollIntervalMilliseconds: focusPollIntervalMilliseconds,
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
isFastModeEnabled: isFastModeEnabled
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference
)
}
}
38 changes: 38 additions & 0 deletions CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

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()
Expand Down