From 9c155931f3447ac8a58d33aa3067140aa9b111c9 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:18:24 -0700 Subject: [PATCH] refactor: group settings by product domain --- Cotabby.xcodeproj/project.pbxproj | 4 + .../Settings/SuggestionSettingsData.swift | 472 +++++++++++++++--- .../Settings/SuggestionSettingsModel.swift | 152 ++++-- .../Settings/SuggestionSettingsStore.swift | 144 +++--- .../SuggestionSettingsDomainTests.swift | 72 +++ 5 files changed, 674 insertions(+), 170 deletions(-) create mode 100644 CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 6d204c23..5a030d86 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -680,6 +680,7 @@ E6C99AB3E1A56E71F3A518FB /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050DC9D858A124FD1CFA4D99 /* ModelDownloadManager.swift */; }; E6DD9EAAFF4001E2922B5F55 /* OnboardingStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */; }; E74FC77608C8000C5E81B28E /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */; }; + E83E989C5132F2A8C8FED8EB /* SuggestionSettingsDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */; }; E846FC1E29526242117F126F /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE86A49339BB507D197629D /* MenuBarPresentationObserver.swift */; }; E8768B22E683F35098DF4CCF /* InlinePreviewPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */; }; E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; @@ -825,6 +826,7 @@ 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSuppressionController.swift; sourceTree = ""; }; 2D8AA55C2B730110E8598F91 /* it-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "it-100k.txt"; sourceTree = ""; }; 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivationIndicatorController.swift; sourceTree = ""; }; + 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsDomainTests.swift; sourceTree = ""; }; 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; 300472C842A2FA08A098BC9E /* SuggestionPresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPresentationModels.swift; sourceTree = ""; }; 309B45D197958D1DA2FE4D53 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; @@ -2377,6 +2379,7 @@ DE29E54D78A9DD7CD87626DD /* LanguageSupportTests.swift */, A306C7B2B0790A0B98D8F353 /* PerDomainDisableSettingsTests.swift */, DA02598F555696782E180DA5 /* ShortcutConflictTests.swift */, + 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */, D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */, ); path = Settings; @@ -3347,6 +3350,7 @@ 311445DF836037B2785E1539 /* SuggestionRequestFactoryTests.swift in Sources */, 78F4CBAD2B7580A29F0F07C5 /* SuggestionSessionReconciliationTests.swift in Sources */, E39A5FA3F8CE7BF6168A073C /* SuggestionSessionTypingTests.swift in Sources */, + E83E989C5132F2A8C8FED8EB /* SuggestionSettingsDomainTests.swift in Sources */, 6139DA1889A939120969FD7A /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */, 2D83CECB0FF076323226449D /* SuggestionSettingsModelTests.swift in Sources */, 19449988F10AD289E2682F04 /* SuggestionSettingsStoreTests.swift in Sources */, diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index 3d86138c..2f8596d1 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -1,111 +1,423 @@ import CoreGraphics import Foundation -/// File overview: -/// The pure value representation of every durable autocomplete preference Cotabby persists. +/// Process-level enablement and host-application policy. /// -/// This is the shape `SuggestionSettingsStore` loads from and saves to `UserDefaults`, and the -/// bag of values the `@MainActor` `SuggestionSettingsModel` facade fans its `@Published` -/// properties out from on launch. Keeping the plain values separate from the `ObservableObject` -/// lets the persistence and migration logic be unit-tested against an injected `UserDefaults` -/// suite without standing up SwiftUI observation. `Equatable` so tests can assert a full -/// round-trip and so the store can compare resolved-versus-stored state. -struct SuggestionSettingsData: Equatable { +/// This value lives for as long as a loaded settings snapshot. It has no persistence behavior of +/// its own; `SuggestionSettingsStore` maps the fields to the existing UserDefaults keys. +struct SuggestionGeneralSettings: Equatable { var isGloballyEnabled: Bool - /// Temporary suppression persists independently from the user's normal global preference. var pauseState: SuggestionPauseState? - var showIndicator: Bool - var showAcceptanceHint: Bool var disabledAppRules: [DisabledApplicationRule] - /// When false (the default), ghost text is suppressed in integrated terminals (VS Code / Cursor - /// xterm.js surfaces); a terminal's own completion/history conflicts with autocomplete and ghost - /// text overlaps command output. Power users can opt back in. var suggestInIntegratedTerminals: Bool - var customSuggestionTextColorHex: String? - var ghostTextOpacity: Double - /// User multiplier applied on top of the caret-approximated ghost-text size. 1.0 keeps the - /// existing best-approximation; lower values shrink suggestions for users who find the auto-size - /// too large. See `SuggestionSettingsStore.clampedGhostTextSizeMultiplier` for the bounds. - var ghostTextSizeMultiplier: Double + var isPerformanceTrackingEnabled: Bool +} + +/// Backend selection, endpoint configuration, and power-source routing. +struct SuggestionEngineSettings: Equatable { var selectedEngine: SuggestionEngineKind var openAICompatibleBaseURL: String var openAICompatibleModelName: String var openAICompatibleAPIMode: OpenAICompatibleAPIMode + var isPowerBasedModelSwitchingEnabled: Bool + var batteryEngine: SuggestionEngineKind + var batteryModelFilename: String + var batteryEndpointModelName: String + var pluggedInEngine: SuggestionEngineKind + var pluggedInModelFilename: String + var pluggedInEndpointModelName: String +} + +/// Completion length, timing, streaming, and acceptance behavior. +struct SuggestionCompletionSettings: Equatable { var selectedWordCountPreset: SuggestionWordCountPreset - /// When true, generation uses `customWordCountLowWords...customWordCountHighWords` instead of - /// the preset above. Stored alongside the preset (not replacing it) so toggling back from Custom - /// restores the user's previous preset choice without having to remember it. var isUsingCustomWordCountRange: Bool var customWordCountLowWords: Int var customWordCountHighWords: Int + var debounceMilliseconds: Int + var focusPollIntervalMilliseconds: Int + var isMultiLineEnabled: Bool + var autoAcceptTrailingPunctuation: Bool + var addSpaceAfterAccept: Bool + var streamSuggestionsWhileGenerating: Bool + var acceptanceGranularity: AcceptanceGranularity +} + +/// Optional request context and user-authored personalization. +struct SuggestionContextSettings: Equatable { var isClipboardContextEnabled: Bool - /// When on (the default), prompts may state which app, window title, web domain, and field the - /// user is typing in, so suggestions stay on-topic for the surface. Everything stays on device. var isSurfaceContextEnabled: Bool var isFastModeEnabled: Bool - /// When on, Cotabby checks the user's current word with `NSSpellChecker` and hides the normal - /// continuation when the word looks misspelled, so completions don't pile onto a broken word. + var userName: String + var customRules: [String] + var responseLanguages: [String] + var extendedContext: String +} + +/// Deterministic spelling and typo-correction policy. +struct SuggestionCorrectionSettings: Equatable { var suppressCompletionsOnTypo: Bool - /// When on (and `suppressCompletionsOnTypo` is also on), a detected typo switches into correction - /// mode: Cotabby offers the spell-checker's fix as a green replace-the-word suggestion. var offerTypoCorrections: Bool - /// ISO language codes for the bundled SymSpell dictionaries the user permits Cotabby to query. - /// Empty means correction ranking relies exclusively on the system `NSSpellChecker`. var enabledSpellingDictionaryCodes: [String] - /// When on (and typo suppression is also on), a completed misspelled word is replaced as soon as - /// the user presses Space. This remains separate from `offerTypoCorrections`: users may keep the - /// green preview while typing, disable it, or use both behaviors together. var automaticallyFixTypos: Bool - var isPerformanceTrackingEnabled: Bool - /// Controls whether SwiftUI inserts Cotabby's `MenuBarExtra` into the system menu bar. The app - /// keeps running when this is false; reopening Cotabby provides the recovery path to Settings. +} + +/// Overlay, indicator, menu-bar, and transition presentation. +struct SuggestionPresentationSettings: Equatable { + var showIndicator: Bool + var showAcceptanceHint: Bool + var customSuggestionTextColorHex: String? + var ghostTextOpacity: Double + var ghostTextSizeMultiplier: Double var isMenuBarIconVisible: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference - var userName: String - var customRules: [String] - var responseLanguages: [String] - var extendedContext: String - var debounceMilliseconds: Int - var focusPollIntervalMilliseconds: Int - var isMultiLineEnabled: Bool + var fadeInSuggestions: Bool + var fadeInDurationSeconds: Double +} + +/// Non-model inline features that share the global input stream. +struct SuggestionInlineFeatureSettings: Equatable { var isEmojiPickerEnabled: Bool var isMacroExpansionEnabled: Bool var preferredEmojiSkinTone: EmojiSkinTone var preferredEmojiGender: EmojiGender - var autoAcceptTrailingPunctuation: Bool - /// When on, accepting a suggestion that finishes a word also types a trailing space, so the user - /// can keep typing the next word without pressing Space. Suppressed when the accepted text already - /// ends in punctuation or whitespace, or in a space-less script. Defaults to off so the WYSIWYG - /// accept behavior is unchanged unless the user opts in. - var addSpaceAfterAccept: Bool - /// When on, ghost text is revealed token-by-token as the model decodes, and each partial is an - /// acceptable session the user can Tab into early. When off (the default), the suggestion appears - /// once, fully formed, after generation finishes. - var streamSuggestionsWhileGenerating: Bool - /// When on (the default), a newly shown suggestion fades in over a short opacity ramp instead of - /// snapping to full strength. Purely cosmetic and consumed only by the overlay renderer; the fade - /// is suppressed automatically when the system "Reduce Motion" setting is on. - var fadeInSuggestions: Bool - /// Duration in seconds of that fade-in ramp. Read live by the overlay renderer and surfaced as a - /// Slow-to-Fast speed slider in Settings; lower is a faster fade. See - /// `SuggestionSettingsStore.clampedFadeInDuration` for the bounds. - var fadeInDurationSeconds: Double - var acceptanceKeyCode: CGKeyCode - var acceptanceKeyModifiers: ShortcutModifierMask - var acceptanceKeyLabel: String - var fullAcceptanceKeyCode: CGKeyCode - var fullAcceptanceKeyModifiers: ShortcutModifierMask - var fullAcceptanceKeyLabel: String - var globalToggleKeyCode: CGKeyCode - var globalToggleKeyModifiers: ShortcutModifierMask - var globalToggleKeyLabel: String - var acceptanceGranularity: AcceptanceGranularity - var isPowerBasedModelSwitchingEnabled: Bool - var batteryEngine: SuggestionEngineKind - var batteryModelFilename: String - var batteryEndpointModelName: String - var pluggedInEngine: SuggestionEngineKind - var pluggedInModelFilename: String - var pluggedInEndpointModelName: String +} + +/// One complete physical key binding. Keeping key code, modifiers, and label together prevents a +/// domain value from representing a half-updated shortcut. +struct SuggestionShortcutBindingSettings: Equatable { + var keyCode: CGKeyCode + var modifiers: ShortcutModifierMask + var label: String +} + +/// The three independently configurable shortcut actions. +struct SuggestionShortcutSettings: Equatable { + var acceptance: SuggestionShortcutBindingSettings + var fullAcceptance: SuggestionShortcutBindingSettings + var globalToggle: SuggestionShortcutBindingSettings +} + +/// Pure domain representation of every durable suggestion preference. +/// +/// Persistence keys remain flat for migration compatibility, while this in-memory value is grouped +/// by the subsystem that owns each decision. The forwarding properties below preserve the previous +/// flat API for existing call sites and tests while new code can consume cohesive domain settings. +struct SuggestionSettingsData: Equatable { + var general: SuggestionGeneralSettings + var engine: SuggestionEngineSettings + var completion: SuggestionCompletionSettings + var context: SuggestionContextSettings + var correction: SuggestionCorrectionSettings + var presentation: SuggestionPresentationSettings + var inlineFeatures: SuggestionInlineFeatureSettings + var shortcuts: SuggestionShortcutSettings +} + +extension SuggestionSettingsData { + var isGloballyEnabled: Bool { + get { general.isGloballyEnabled } + set { general.isGloballyEnabled = newValue } + } + + var pauseState: SuggestionPauseState? { + get { general.pauseState } + set { general.pauseState = newValue } + } + + var disabledAppRules: [DisabledApplicationRule] { + get { general.disabledAppRules } + set { general.disabledAppRules = newValue } + } + + var suggestInIntegratedTerminals: Bool { + get { general.suggestInIntegratedTerminals } + set { general.suggestInIntegratedTerminals = newValue } + } + + var isPerformanceTrackingEnabled: Bool { + get { general.isPerformanceTrackingEnabled } + set { general.isPerformanceTrackingEnabled = newValue } + } + + var selectedEngine: SuggestionEngineKind { + get { engine.selectedEngine } + set { engine.selectedEngine = newValue } + } + + var openAICompatibleBaseURL: String { + get { engine.openAICompatibleBaseURL } + set { engine.openAICompatibleBaseURL = newValue } + } + + var openAICompatibleModelName: String { + get { engine.openAICompatibleModelName } + set { engine.openAICompatibleModelName = newValue } + } + + var openAICompatibleAPIMode: OpenAICompatibleAPIMode { + get { engine.openAICompatibleAPIMode } + set { engine.openAICompatibleAPIMode = newValue } + } + + var isPowerBasedModelSwitchingEnabled: Bool { + get { engine.isPowerBasedModelSwitchingEnabled } + set { engine.isPowerBasedModelSwitchingEnabled = newValue } + } + + var batteryEngine: SuggestionEngineKind { + get { engine.batteryEngine } + set { engine.batteryEngine = newValue } + } + + var batteryModelFilename: String { + get { engine.batteryModelFilename } + set { engine.batteryModelFilename = newValue } + } + + var batteryEndpointModelName: String { + get { engine.batteryEndpointModelName } + set { engine.batteryEndpointModelName = newValue } + } + + var pluggedInEngine: SuggestionEngineKind { + get { engine.pluggedInEngine } + set { engine.pluggedInEngine = newValue } + } + + var pluggedInModelFilename: String { + get { engine.pluggedInModelFilename } + set { engine.pluggedInModelFilename = newValue } + } + + var pluggedInEndpointModelName: String { + get { engine.pluggedInEndpointModelName } + set { engine.pluggedInEndpointModelName = newValue } + } + + var selectedWordCountPreset: SuggestionWordCountPreset { + get { completion.selectedWordCountPreset } + set { completion.selectedWordCountPreset = newValue } + } + + var isUsingCustomWordCountRange: Bool { + get { completion.isUsingCustomWordCountRange } + set { completion.isUsingCustomWordCountRange = newValue } + } + + var customWordCountLowWords: Int { + get { completion.customWordCountLowWords } + set { completion.customWordCountLowWords = newValue } + } + + var customWordCountHighWords: Int { + get { completion.customWordCountHighWords } + set { completion.customWordCountHighWords = newValue } + } + + var debounceMilliseconds: Int { + get { completion.debounceMilliseconds } + set { completion.debounceMilliseconds = newValue } + } + + var focusPollIntervalMilliseconds: Int { + get { completion.focusPollIntervalMilliseconds } + set { completion.focusPollIntervalMilliseconds = newValue } + } + + var isMultiLineEnabled: Bool { + get { completion.isMultiLineEnabled } + set { completion.isMultiLineEnabled = newValue } + } + + var autoAcceptTrailingPunctuation: Bool { + get { completion.autoAcceptTrailingPunctuation } + set { completion.autoAcceptTrailingPunctuation = newValue } + } + + var addSpaceAfterAccept: Bool { + get { completion.addSpaceAfterAccept } + set { completion.addSpaceAfterAccept = newValue } + } + + var streamSuggestionsWhileGenerating: Bool { + get { completion.streamSuggestionsWhileGenerating } + set { completion.streamSuggestionsWhileGenerating = newValue } + } + + var acceptanceGranularity: AcceptanceGranularity { + get { completion.acceptanceGranularity } + set { completion.acceptanceGranularity = newValue } + } + + var isClipboardContextEnabled: Bool { + get { context.isClipboardContextEnabled } + set { context.isClipboardContextEnabled = newValue } + } + + var isSurfaceContextEnabled: Bool { + get { context.isSurfaceContextEnabled } + set { context.isSurfaceContextEnabled = newValue } + } + + var isFastModeEnabled: Bool { + get { context.isFastModeEnabled } + set { context.isFastModeEnabled = newValue } + } + + var userName: String { + get { context.userName } + set { context.userName = newValue } + } + + var customRules: [String] { + get { context.customRules } + set { context.customRules = newValue } + } + + var responseLanguages: [String] { + get { context.responseLanguages } + set { context.responseLanguages = newValue } + } + + var extendedContext: String { + get { context.extendedContext } + set { context.extendedContext = newValue } + } + + var suppressCompletionsOnTypo: Bool { + get { correction.suppressCompletionsOnTypo } + set { correction.suppressCompletionsOnTypo = newValue } + } + + var offerTypoCorrections: Bool { + get { correction.offerTypoCorrections } + set { correction.offerTypoCorrections = newValue } + } + + var enabledSpellingDictionaryCodes: [String] { + get { correction.enabledSpellingDictionaryCodes } + set { correction.enabledSpellingDictionaryCodes = newValue } + } + + var automaticallyFixTypos: Bool { + get { correction.automaticallyFixTypos } + set { correction.automaticallyFixTypos = newValue } + } + + var showIndicator: Bool { + get { presentation.showIndicator } + set { presentation.showIndicator = newValue } + } + + var showAcceptanceHint: Bool { + get { presentation.showAcceptanceHint } + set { presentation.showAcceptanceHint = newValue } + } + + var customSuggestionTextColorHex: String? { + get { presentation.customSuggestionTextColorHex } + set { presentation.customSuggestionTextColorHex = newValue } + } + + var ghostTextOpacity: Double { + get { presentation.ghostTextOpacity } + set { presentation.ghostTextOpacity = newValue } + } + + var ghostTextSizeMultiplier: Double { + get { presentation.ghostTextSizeMultiplier } + set { presentation.ghostTextSizeMultiplier = newValue } + } + + var isMenuBarIconVisible: Bool { + get { presentation.isMenuBarIconVisible } + set { presentation.isMenuBarIconVisible = newValue } + } + + var isMenuBarWordCountVisible: Bool { + get { presentation.isMenuBarWordCountVisible } + set { presentation.isMenuBarWordCountVisible = newValue } + } + + var mirrorPreference: MirrorPreference { + get { presentation.mirrorPreference } + set { presentation.mirrorPreference = newValue } + } + + var fadeInSuggestions: Bool { + get { presentation.fadeInSuggestions } + set { presentation.fadeInSuggestions = newValue } + } + + var fadeInDurationSeconds: Double { + get { presentation.fadeInDurationSeconds } + set { presentation.fadeInDurationSeconds = newValue } + } + + var isEmojiPickerEnabled: Bool { + get { inlineFeatures.isEmojiPickerEnabled } + set { inlineFeatures.isEmojiPickerEnabled = newValue } + } + + var isMacroExpansionEnabled: Bool { + get { inlineFeatures.isMacroExpansionEnabled } + set { inlineFeatures.isMacroExpansionEnabled = newValue } + } + + var preferredEmojiSkinTone: EmojiSkinTone { + get { inlineFeatures.preferredEmojiSkinTone } + set { inlineFeatures.preferredEmojiSkinTone = newValue } + } + + var preferredEmojiGender: EmojiGender { + get { inlineFeatures.preferredEmojiGender } + set { inlineFeatures.preferredEmojiGender = newValue } + } + + var acceptanceKeyCode: CGKeyCode { + get { shortcuts.acceptance.keyCode } + set { shortcuts.acceptance.keyCode = newValue } + } + + var acceptanceKeyModifiers: ShortcutModifierMask { + get { shortcuts.acceptance.modifiers } + set { shortcuts.acceptance.modifiers = newValue } + } + + var acceptanceKeyLabel: String { + get { shortcuts.acceptance.label } + set { shortcuts.acceptance.label = newValue } + } + + var fullAcceptanceKeyCode: CGKeyCode { + get { shortcuts.fullAcceptance.keyCode } + set { shortcuts.fullAcceptance.keyCode = newValue } + } + + var fullAcceptanceKeyModifiers: ShortcutModifierMask { + get { shortcuts.fullAcceptance.modifiers } + set { shortcuts.fullAcceptance.modifiers = newValue } + } + + var fullAcceptanceKeyLabel: String { + get { shortcuts.fullAcceptance.label } + set { shortcuts.fullAcceptance.label = newValue } + } + + var globalToggleKeyCode: CGKeyCode { + get { shortcuts.globalToggle.keyCode } + set { shortcuts.globalToggle.keyCode = newValue } + } + + var globalToggleKeyModifiers: ShortcutModifierMask { + get { shortcuts.globalToggle.modifiers } + set { shortcuts.globalToggle.modifiers = newValue } + } + + var globalToggleKeyLabel: String { + get { shortcuts.globalToggle.label } + set { shortcuts.globalToggle.label = newValue } + } } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 8f2b27f2..847b09b8 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -348,38 +348,132 @@ final class SuggestionSettingsModel: ObservableObject { showIndicator } + /// Cohesive read model for non-UI consumers and persistence-oriented tests. + /// + /// SwiftUI-facing properties remain individually `@Published` for source compatibility. This + /// projection gives new code subsystem-owned settings without duplicating durable state or + /// changing when `objectWillChange` fires. + var domainSettings: SuggestionSettingsData { + SuggestionSettingsData( + general: SuggestionGeneralSettings( + isGloballyEnabled: isGloballyEnabled, + pauseState: pauseState, + disabledAppRules: disabledAppRules, + suggestInIntegratedTerminals: suggestInIntegratedTerminals, + isPerformanceTrackingEnabled: isPerformanceTrackingEnabled + ), + engine: SuggestionEngineSettings( + selectedEngine: selectedEngine, + openAICompatibleBaseURL: openAICompatibleBaseURL, + openAICompatibleModelName: openAICompatibleModelName, + openAICompatibleAPIMode: openAICompatibleAPIMode, + isPowerBasedModelSwitchingEnabled: isPowerBasedModelSwitchingEnabled, + batteryEngine: batteryEngine, + batteryModelFilename: batteryModelFilename, + batteryEndpointModelName: batteryEndpointModelName, + pluggedInEngine: pluggedInEngine, + pluggedInModelFilename: pluggedInModelFilename, + pluggedInEndpointModelName: pluggedInEndpointModelName + ), + completion: SuggestionCompletionSettings( + selectedWordCountPreset: selectedWordCountPreset, + isUsingCustomWordCountRange: isUsingCustomWordCountRange, + customWordCountLowWords: customWordCountLowWords, + customWordCountHighWords: customWordCountHighWords, + debounceMilliseconds: debounceMilliseconds, + focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, + isMultiLineEnabled: isMultiLineEnabled, + autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, + addSpaceAfterAccept: addSpaceAfterAccept, + streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating, + acceptanceGranularity: acceptanceGranularity + ), + context: SuggestionContextSettings( + isClipboardContextEnabled: isClipboardContextEnabled, + isSurfaceContextEnabled: isSurfaceContextEnabled, + isFastModeEnabled: isFastModeEnabled, + userName: userName, + customRules: customRules, + responseLanguages: responseLanguages, + extendedContext: extendedContext + ), + correction: SuggestionCorrectionSettings( + suppressCompletionsOnTypo: suppressCompletionsOnTypo, + offerTypoCorrections: offerTypoCorrections, + enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, + automaticallyFixTypos: automaticallyFixTypos + ), + presentation: SuggestionPresentationSettings( + showIndicator: showIndicator, + showAcceptanceHint: showAcceptanceHint, + customSuggestionTextColorHex: customSuggestionTextColorHex, + ghostTextOpacity: ghostTextOpacity, + ghostTextSizeMultiplier: ghostTextSizeMultiplier, + isMenuBarIconVisible: isMenuBarIconVisible, + isMenuBarWordCountVisible: isMenuBarWordCountVisible, + mirrorPreference: mirrorPreference, + fadeInSuggestions: fadeInSuggestions, + fadeInDurationSeconds: fadeInDurationSeconds + ), + inlineFeatures: SuggestionInlineFeatureSettings( + isEmojiPickerEnabled: isEmojiPickerEnabled, + isMacroExpansionEnabled: isMacroExpansionEnabled, + preferredEmojiSkinTone: preferredEmojiSkinTone, + preferredEmojiGender: preferredEmojiGender + ), + shortcuts: SuggestionShortcutSettings( + acceptance: SuggestionShortcutBindingSettings( + keyCode: acceptanceKeyCode, + modifiers: acceptanceKeyModifiers, + label: acceptanceKeyLabel + ), + fullAcceptance: SuggestionShortcutBindingSettings( + keyCode: fullAcceptanceKeyCode, + modifiers: fullAcceptanceKeyModifiers, + label: fullAcceptanceKeyLabel + ), + globalToggle: SuggestionShortcutBindingSettings( + keyCode: globalToggleKeyCode, + modifiers: globalToggleKeyModifiers, + label: globalToggleKeyLabel + ) + ) + ) + } + var snapshot: SuggestionSettingsSnapshot { - SuggestionSettingsSnapshot( - isGloballyEnabled: isGloballyEnabled, - isTemporarilyPaused: isTemporarilyPaused, - disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), - suggestInIntegratedTerminals: suggestInIntegratedTerminals, - selectedEngine: selectedEngine, - selectedWordCountPreset: selectedWordCountPreset, - isUsingCustomWordCountRange: isUsingCustomWordCountRange, + let settings = domainSettings + return SuggestionSettingsSnapshot( + isGloballyEnabled: settings.general.isGloballyEnabled, + isTemporarilyPaused: settings.general.pauseState?.isActive() == true, + disabledAppBundleIdentifiers: Set(settings.general.disabledAppRules.map(\.bundleIdentifier)), + suggestInIntegratedTerminals: settings.general.suggestInIntegratedTerminals, + selectedEngine: settings.engine.selectedEngine, + selectedWordCountPreset: settings.completion.selectedWordCountPreset, + isUsingCustomWordCountRange: settings.completion.isUsingCustomWordCountRange, customWordCountRange: SuggestionWordRange.clamped( - low: customWordCountLowWords, - high: customWordCountHighWords + low: settings.completion.customWordCountLowWords, + high: settings.completion.customWordCountHighWords ), - isClipboardContextEnabled: isClipboardContextEnabled, - isSurfaceContextEnabled: isSurfaceContextEnabled, - userName: userName, - customRules: customRules, - extendedContext: extendedContext, - responseLanguages: responseLanguages, - debounceMilliseconds: debounceMilliseconds, - focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, - isMultiLineEnabled: isMultiLineEnabled, - autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, - addSpaceAfterAccept: addSpaceAfterAccept, - streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating, - isFastModeEnabled: isFastModeEnabled, - mirrorPreference: mirrorPreference, - acceptanceGranularity: acceptanceGranularity, - suppressCompletionsOnTypo: suppressCompletionsOnTypo, - offerTypoCorrections: offerTypoCorrections, - enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + isClipboardContextEnabled: settings.context.isClipboardContextEnabled, + isSurfaceContextEnabled: settings.context.isSurfaceContextEnabled, + userName: settings.context.userName, + customRules: settings.context.customRules, + extendedContext: settings.context.extendedContext, + responseLanguages: settings.context.responseLanguages, + debounceMilliseconds: settings.completion.debounceMilliseconds, + focusPollIntervalMilliseconds: settings.completion.focusPollIntervalMilliseconds, + isMultiLineEnabled: settings.completion.isMultiLineEnabled, + autoAcceptTrailingPunctuation: settings.completion.autoAcceptTrailingPunctuation, + addSpaceAfterAccept: settings.completion.addSpaceAfterAccept, + streamSuggestionsWhileGenerating: settings.completion.streamSuggestionsWhileGenerating, + isFastModeEnabled: settings.context.isFastModeEnabled, + mirrorPreference: settings.presentation.mirrorPreference, + acceptanceGranularity: settings.completion.acceptanceGranularity, + suppressCompletionsOnTypo: settings.correction.suppressCompletionsOnTypo, + offerTypoCorrections: settings.correction.offerTypoCorrections, + enabledSpellingDictionaryCodes: settings.correction.enabledSpellingDictionaryCodes, + automaticallyFixTypos: settings.correction.automaticallyFixTypos ) } diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 045e0e48..8326fae2 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -480,67 +480,89 @@ struct SuggestionSettingsStore { .string(forKey: Self.pluggedInEndpointModelNameDefaultsKey) ?? "" let data = SuggestionSettingsData( - isGloballyEnabled: resolvedGloballyEnabled, - pauseState: resolvedPauseState, - showIndicator: resolvedShowIndicator, - showAcceptanceHint: resolvedShowAcceptanceHint, - disabledAppRules: resolvedDisabledAppRules, - suggestInIntegratedTerminals: resolvedSuggestInIntegratedTerminals, - customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, - ghostTextOpacity: resolvedGhostTextOpacity, - ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, - selectedEngine: resolvedEngine, - openAICompatibleBaseURL: resolvedOpenAICompatibleBaseURL, - openAICompatibleModelName: resolvedOpenAICompatibleModelName, - openAICompatibleAPIMode: resolvedOpenAICompatibleAPIMode, - selectedWordCountPreset: resolvedWordCountPreset, - isUsingCustomWordCountRange: resolvedUsingCustomWordCountRange, - customWordCountLowWords: resolvedCustomRange.lowWords, - customWordCountHighWords: resolvedCustomRange.highWords, - isClipboardContextEnabled: resolvedClipboardContextEnabled, - isSurfaceContextEnabled: resolvedSurfaceContextEnabled, - isFastModeEnabled: resolvedFastModeEnabled, - suppressCompletionsOnTypo: resolvedSuppressCompletionsOnTypo, - offerTypoCorrections: resolvedOfferTypoCorrections, - enabledSpellingDictionaryCodes: resolvedEnabledSpellingDictionaryCodes, - automaticallyFixTypos: resolvedAutomaticallyFixTypos, - isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled, - isMenuBarIconVisible: resolvedMenuBarIconVisible, - isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, - mirrorPreference: resolvedMirrorPreference, - userName: resolvedUserName, - customRules: resolvedCustomRules, - responseLanguages: resolvedResponseLanguages, - extendedContext: resolvedExtendedContext, - debounceMilliseconds: resolvedDebounceMilliseconds, - focusPollIntervalMilliseconds: resolvedFocusPollIntervalMilliseconds, - isMultiLineEnabled: resolvedMultiLineEnabled, - isEmojiPickerEnabled: resolvedEmojiPickerEnabled, - isMacroExpansionEnabled: resolvedMacroExpansionEnabled, - preferredEmojiSkinTone: resolvedPreferredEmojiSkinTone, - preferredEmojiGender: resolvedPreferredEmojiGender, - autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation, - addSpaceAfterAccept: resolvedAddSpaceAfterAccept, - streamSuggestionsWhileGenerating: resolvedStreamSuggestionsWhileGenerating, - fadeInSuggestions: resolvedFadeInSuggestions, - fadeInDurationSeconds: resolvedFadeInDurationSeconds, - acceptanceKeyCode: resolvedAcceptanceKeyCode, - acceptanceKeyModifiers: resolvedAcceptanceKeyModifiers, - acceptanceKeyLabel: resolvedAcceptanceKeyLabel, - fullAcceptanceKeyCode: resolvedFullAcceptanceKeyCode, - fullAcceptanceKeyModifiers: resolvedFullAcceptanceKeyModifiers, - fullAcceptanceKeyLabel: resolvedFullAcceptanceKeyLabel, - globalToggleKeyCode: resolvedGlobalToggleKeyCode, - globalToggleKeyModifiers: resolvedGlobalToggleKeyModifiers, - globalToggleKeyLabel: resolvedGlobalToggleKeyLabel, - acceptanceGranularity: resolvedAcceptanceGranularity, - isPowerBasedModelSwitchingEnabled: resolvedPowerBasedModelSwitchingEnabled, - batteryEngine: resolvedBatteryEngine, - batteryModelFilename: resolvedBatteryModelFilename, - batteryEndpointModelName: resolvedBatteryEndpointModelName, - pluggedInEngine: resolvedPluggedInEngine, - pluggedInModelFilename: resolvedPluggedInModelFilename, - pluggedInEndpointModelName: resolvedPluggedInEndpointModelName + general: SuggestionGeneralSettings( + isGloballyEnabled: resolvedGloballyEnabled, + pauseState: resolvedPauseState, + disabledAppRules: resolvedDisabledAppRules, + suggestInIntegratedTerminals: resolvedSuggestInIntegratedTerminals, + isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled + ), + engine: SuggestionEngineSettings( + selectedEngine: resolvedEngine, + openAICompatibleBaseURL: resolvedOpenAICompatibleBaseURL, + openAICompatibleModelName: resolvedOpenAICompatibleModelName, + openAICompatibleAPIMode: resolvedOpenAICompatibleAPIMode, + isPowerBasedModelSwitchingEnabled: resolvedPowerBasedModelSwitchingEnabled, + batteryEngine: resolvedBatteryEngine, + batteryModelFilename: resolvedBatteryModelFilename, + batteryEndpointModelName: resolvedBatteryEndpointModelName, + pluggedInEngine: resolvedPluggedInEngine, + pluggedInModelFilename: resolvedPluggedInModelFilename, + pluggedInEndpointModelName: resolvedPluggedInEndpointModelName + ), + completion: SuggestionCompletionSettings( + selectedWordCountPreset: resolvedWordCountPreset, + isUsingCustomWordCountRange: resolvedUsingCustomWordCountRange, + customWordCountLowWords: resolvedCustomRange.lowWords, + customWordCountHighWords: resolvedCustomRange.highWords, + debounceMilliseconds: resolvedDebounceMilliseconds, + focusPollIntervalMilliseconds: resolvedFocusPollIntervalMilliseconds, + isMultiLineEnabled: resolvedMultiLineEnabled, + autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation, + addSpaceAfterAccept: resolvedAddSpaceAfterAccept, + streamSuggestionsWhileGenerating: resolvedStreamSuggestionsWhileGenerating, + acceptanceGranularity: resolvedAcceptanceGranularity + ), + context: SuggestionContextSettings( + isClipboardContextEnabled: resolvedClipboardContextEnabled, + isSurfaceContextEnabled: resolvedSurfaceContextEnabled, + isFastModeEnabled: resolvedFastModeEnabled, + userName: resolvedUserName, + customRules: resolvedCustomRules, + responseLanguages: resolvedResponseLanguages, + extendedContext: resolvedExtendedContext + ), + correction: SuggestionCorrectionSettings( + suppressCompletionsOnTypo: resolvedSuppressCompletionsOnTypo, + offerTypoCorrections: resolvedOfferTypoCorrections, + enabledSpellingDictionaryCodes: resolvedEnabledSpellingDictionaryCodes, + automaticallyFixTypos: resolvedAutomaticallyFixTypos + ), + presentation: SuggestionPresentationSettings( + showIndicator: resolvedShowIndicator, + showAcceptanceHint: resolvedShowAcceptanceHint, + customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, + ghostTextOpacity: resolvedGhostTextOpacity, + ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, + isMenuBarIconVisible: resolvedMenuBarIconVisible, + isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, + mirrorPreference: resolvedMirrorPreference, + fadeInSuggestions: resolvedFadeInSuggestions, + fadeInDurationSeconds: resolvedFadeInDurationSeconds + ), + inlineFeatures: SuggestionInlineFeatureSettings( + isEmojiPickerEnabled: resolvedEmojiPickerEnabled, + isMacroExpansionEnabled: resolvedMacroExpansionEnabled, + preferredEmojiSkinTone: resolvedPreferredEmojiSkinTone, + preferredEmojiGender: resolvedPreferredEmojiGender + ), + shortcuts: SuggestionShortcutSettings( + acceptance: SuggestionShortcutBindingSettings( + keyCode: resolvedAcceptanceKeyCode, + modifiers: resolvedAcceptanceKeyModifiers, + label: resolvedAcceptanceKeyLabel + ), + fullAcceptance: SuggestionShortcutBindingSettings( + keyCode: resolvedFullAcceptanceKeyCode, + modifiers: resolvedFullAcceptanceKeyModifiers, + label: resolvedFullAcceptanceKeyLabel + ), + globalToggle: SuggestionShortcutBindingSettings( + keyCode: resolvedGlobalToggleKeyCode, + modifiers: resolvedGlobalToggleKeyModifiers, + label: resolvedGlobalToggleKeyLabel + ) + ) ) // Unconditional write-back so the resolved (possibly migrated or default-capped) values are diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift new file mode 100644 index 00000000..34a3a3e2 --- /dev/null +++ b/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import Cotabby + +/// Verifies that domain grouping is an in-memory ownership change, not a persistence migration. +/// Existing flat accessors remain part of the compatibility seam while new code reads cohesive +/// general, engine, completion, context, correction, presentation, inline-feature, and shortcut values. +@MainActor +final class SuggestionSettingsDomainTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "cotabby.test.settingsDomains.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func test_storeLoad_groupsExistingPersistenceKeysByOwningDomain() { + defaults.set(false, forKey: "cotabbyGloballyEnabled") + defaults.set(SuggestionEngineKind.appleIntelligence.rawValue, forKey: "cotabbySelectedEngine") + defaults.set(true, forKey: "cotabbyClipboardContextEnabled") + defaults.set(false, forKey: "cotabbyShowAcceptanceHint") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertFalse(data.general.isGloballyEnabled) + XCTAssertEqual(data.engine.selectedEngine, .appleIntelligence) + XCTAssertTrue(data.context.isClipboardContextEnabled) + XCTAssertFalse(data.presentation.showAcceptanceHint) + XCTAssertEqual(data.shortcuts.acceptance.keyCode, SuggestionSettingsStore.defaultAcceptanceKeyCode) + } + + func test_flatCompatibilityAccessors_andDomainValuesStayBidirectionallyConsistent() { + var data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + data.openAICompatibleModelName = "forwarded-model" + data.completion.acceptanceGranularity = .phrase + data.ghostTextOpacity = 0.7 + data.shortcuts.globalToggle.label = "⌥G" + + XCTAssertEqual(data.engine.openAICompatibleModelName, "forwarded-model") + XCTAssertEqual(data.acceptanceGranularity, .phrase) + XCTAssertEqual(data.presentation.ghostTextOpacity, 0.7) + XCTAssertEqual(data.globalToggleKeyLabel, "⌥G") + } + + func test_modelDomainProjection_preservesFlatPropertiesAndGenerationSnapshot() { + let model = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + model.selectEngine(.openAICompatible) + model.setOpenAICompatibleModelName("domain-model") + model.setFastModeEnabled(true) + model.setOfferTypoCorrections(false) + model.setAcceptanceGranularity(.phrase) + + let domains = model.domainSettings + + XCTAssertEqual(domains.engine.selectedEngine, model.selectedEngine) + XCTAssertEqual(domains.engine.openAICompatibleModelName, "domain-model") + XCTAssertTrue(domains.context.isFastModeEnabled) + XCTAssertFalse(domains.correction.offerTypoCorrections) + XCTAssertEqual(domains.completion.acceptanceGranularity, model.snapshot.acceptanceGranularity) + XCTAssertEqual(model.snapshot.selectedEngine, .openAICompatible) + } +}