diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 8d796487..8a32feb3 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -96,9 +96,15 @@ extension SuggestionCoordinator { precedingText: liveContext.precedingText ) + let insertionText = insertionTextApplyingAutoSpace( + insertionChunk: insertionChunk, + acceptedChunk: acceptedChunk, + session: sessionForAcceptance + ) + // An empty chunk means the accepted span was entirely a boundary space the field already // supplies: advance the session without synthesizing a keystroke. - if !insertionChunk.isEmpty, !suggestionInserter.insert(insertionChunk) { + if !insertionText.isEmpty, !suggestionInserter.insert(insertionText) { let message = suggestionInserter.lastErrorMessage ?? "Suggestion insertion failed." cancelPredictionWork() clearSuggestion(clearDiagnostics: true) @@ -109,7 +115,7 @@ extension SuggestionCoordinator { workID: currentWorkID, generation: liveContext.generation, message: message, - normalizedOutput: insertionChunk + normalizedOutput: insertionText ) return false } @@ -190,6 +196,27 @@ extension SuggestionCoordinator { } } + /// Applies the opt-in "add a space after accepting" setting to the text about to be inserted. + /// + /// The trailing space is only appended when this accept *exhausts* the suggestion — predicted the + /// same way `commitAcceptedChunk` decides it — because a mid-suggestion word accept is already + /// followed by the next chunk's own leading space, so a space here would double up. Only the + /// inserted text grows: session accounting still advances by the unchanged `acceptedChunk`, and + /// the session tears down on exhaustion, so the extra space never disturbs the consumed-suffix + /// reconciliation a still-live session relies on. Whether the space actually lands (vs. being + /// suppressed after punctuation, whitespace, or a space-less script) is the reconciler's rule. + private func insertionTextApplyingAutoSpace( + insertionChunk: String, + acceptedChunk: String, + session: ActiveSuggestionSession + ) -> String { + guard settingsSnapshot.addSpaceAfterAccept, + session.advancing(by: acceptedChunk.count).isExhausted else { + return insertionChunk + } + return SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(insertionChunk) + } + /// Runs acceptance bookkeeping one runloop hop after the consuming tap callback returns. /// /// While ghost text is visible the accept tap gates every keyDown system-wide, and the whole diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index d8fe42a7..50dcf567 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -113,6 +113,11 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// When true (the default), accepting a word also takes punctuation attached to it. When false, /// trailing punctuation is left as its own acceptance part so a single Tab takes the word alone. let autoAcceptTrailingPunctuation: Bool + /// When true, an accept that finishes a word and exhausts the suggestion also types a trailing + /// space so the user can keep typing without pressing Space. Suppressed for text ending in + /// punctuation, whitespace, or a space-less script. Defaults to false; travels in the snapshot so + /// the acceptance path reads the live value without subscribing to the settings model. + let addSpaceAfterAccept: Bool /// 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 diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index dd5a5c24..c4f5fbe0 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -63,6 +63,11 @@ struct SuggestionSettingsData: Equatable { 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 var acceptanceKeyCode: CGKeyCode var acceptanceKeyModifiers: ShortcutModifierMask var acceptanceKeyLabel: String diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 5734b61b..4a58151c 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -99,6 +99,7 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var preferredEmojiSkinTone: EmojiSkinTone @Published private(set) var preferredEmojiGender: EmojiGender @Published private(set) var autoAcceptTrailingPunctuation: Bool + @Published private(set) var addSpaceAfterAccept: Bool @Published private(set) var acceptanceKeyCode: CGKeyCode @Published private(set) var acceptanceKeyModifiers: ShortcutModifierMask @Published private(set) var acceptanceKeyLabel: String @@ -181,6 +182,7 @@ final class SuggestionSettingsModel: ObservableObject { preferredEmojiSkinTone = data.preferredEmojiSkinTone preferredEmojiGender = data.preferredEmojiGender autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation + addSpaceAfterAccept = data.addSpaceAfterAccept acceptanceKeyCode = data.acceptanceKeyCode acceptanceKeyModifiers = data.acceptanceKeyModifiers acceptanceKeyLabel = data.acceptanceKeyLabel @@ -224,6 +226,7 @@ final class SuggestionSettingsModel: ObservableObject { focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, + addSpaceAfterAccept: addSpaceAfterAccept, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity, @@ -510,6 +513,14 @@ final class SuggestionSettingsModel: ObservableObject { store.saveAutoAcceptTrailingPunctuation(enabled) } + func setAddSpaceAfterAccept(_ enabled: Bool) { + guard addSpaceAfterAccept != enabled else { + return + } + addSpaceAfterAccept = enabled + store.saveAddSpaceAfterAccept(enabled) + } + func setAcceptanceGranularity(_ granularity: AcceptanceGranularity) { guard acceptanceGranularity != granularity else { return @@ -920,11 +931,13 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $responseLanguages, $enabledSpellingDictionaryCodes ), + // The two acceptance toggles share this slot via a paired `CombineLatest` so the new + // setting costs no extra upstream in a tuple already at Combine's four-input cap. Publishers.CombineLatest4( $debounceMilliseconds, $focusPollIntervalMilliseconds, $isMultiLineEnabled, - $autoAcceptTrailingPunctuation + Publishers.CombineLatest($autoAcceptTrailingPunctuation, $addSpaceAfterAccept) ) ) // The outer CombineLatest stack is already at Combine's per-operator cap, so each new @@ -950,7 +963,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles let (suppressOnTypo, offerCorrections, automaticallyFixTypos) = typoToggles let (userName, customRules, responseLanguages, enabledSpellingDictionaryCodes) = profile - let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing + let (debounce, focusPoll, multiLine, acceptToggles) = timing + let (autoAcceptPunctuation, addSpaceAfterAccept) = acceptToggles let (isCustomActive, customLow, customHigh) = customRangeTuple let (extendedContext, suggestInIntegratedTerminals) = extendedContextTuple return SuggestionSettingsSnapshot( @@ -970,6 +984,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { focusPollIntervalMilliseconds: focusPoll, isMultiLineEnabled: multiLine, autoAcceptTrailingPunctuation: autoAcceptPunctuation, + addSpaceAfterAccept: addSpaceAfterAccept, isFastModeEnabled: fastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: granularity, diff --git a/Cotabby/Support/SuggestionSessionReconciler.swift b/Cotabby/Support/SuggestionSessionReconciler.swift index 9ea574e3..e741d385 100644 --- a/Cotabby/Support/SuggestionSessionReconciler.swift +++ b/Cotabby/Support/SuggestionSessionReconciler.swift @@ -493,6 +493,27 @@ enum SuggestionSessionReconciler { return String(chunk.drop(while: { $0.unicodeScalars.allSatisfy(CharacterSet.whitespaces.contains) })) } + /// Appends a single trailing space to the text inserted by an accept that *exhausts* the + /// suggestion, so the user can keep typing the next word without reaching for the space bar. + /// + /// The caller gates this on exhaustion: only the final chunk of a suggestion is eligible. A + /// mid-suggestion word accept is already followed by the next chunk's own leading space, so a + /// space here would double it. The space is also suppressed unless the inserted text ends on a + /// finished word — a letter or digit that is not a space-less-script (CJK, Thai, ...) glyph. + /// Trailing punctuation (`done.`, `(yes)`, `really?!`) and existing whitespace already mark a + /// boundary, and space-less scripts never separate words with spaces, so for all of those the + /// chunk is returned untouched. This is the opt-in counterpart to the WYSIWYG default that + /// `insertionChunk` documents: the space is a deliberate convenience the user enabled, not a + /// separator silently synthesized behind a suggestion they never saw. + static func insertionChunkAppendingTrailingSpace(_ chunk: String) -> String { + guard let last = chunk.last, + last.isAcceptanceWordCharacter, + !last.beginsSpacelessScriptWord else { + return chunk + } + return chunk + " " + } + /// Counts word-like tokens so punctuation-only accepts do not inflate productivity metrics. static func acceptedWordCount(in text: String) -> Int { text diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index d6aade15..6f01f1df 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -104,6 +104,7 @@ struct SuggestionSettingsStore { private static let preferredEmojiSkinToneDefaultsKey = "cotabbyPreferredEmojiSkinTone" private static let preferredEmojiGenderDefaultsKey = "cotabbyPreferredEmojiGender" private static let autoAcceptTrailingPunctuationDefaultsKey = "cotabbyAutoAcceptTrailingPunctuation" + private static let addSpaceAfterAcceptDefaultsKey = "cotabbyAddSpaceAfterAccept" private static let acceptanceKeyCodeDefaultsKey = "cotabbyAcceptanceKeyCode" private static let acceptanceKeyModifiersDefaultsKey = "cotabbyAcceptanceKeyModifiers" private static let acceptanceKeyLabelDefaultsKey = "cotabbyAcceptanceKeyLabel" @@ -280,6 +281,10 @@ struct SuggestionSettingsStore { .flatMap(EmojiGender.init(rawValue:)) ?? .neutral let resolvedAutoAcceptTrailingPunctuation = userDefaults.object(forKey: Self.autoAcceptTrailingPunctuationDefaultsKey) as? Bool ?? true + // Defaults to false so the WYSIWYG accept behavior is unchanged for existing installs; the + // trailing space is opt-in from Settings. + let resolvedAddSpaceAfterAccept = + userDefaults.object(forKey: Self.addSpaceAfterAcceptDefaultsKey) as? Bool ?? false let resolvedAcceptanceKeyCode = CGKeyCode( userDefaults.object(forKey: Self.acceptanceKeyCodeDefaultsKey) as? Int @@ -366,6 +371,7 @@ struct SuggestionSettingsStore { preferredEmojiSkinTone: resolvedPreferredEmojiSkinTone, preferredEmojiGender: resolvedPreferredEmojiGender, autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation, + addSpaceAfterAccept: resolvedAddSpaceAfterAccept, acceptanceKeyCode: resolvedAcceptanceKeyCode, acceptanceKeyModifiers: resolvedAcceptanceKeyModifiers, acceptanceKeyLabel: resolvedAcceptanceKeyLabel, @@ -418,6 +424,7 @@ struct SuggestionSettingsStore { savePreferredEmojiSkinTone(data.preferredEmojiSkinTone) savePreferredEmojiGender(data.preferredEmojiGender) saveAutoAcceptTrailingPunctuation(data.autoAcceptTrailingPunctuation) + saveAddSpaceAfterAccept(data.addSpaceAfterAccept) saveAcceptanceKey( keyCode: data.acceptanceKeyCode, modifiers: data.acceptanceKeyModifiers, @@ -623,6 +630,10 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.autoAcceptTrailingPunctuationDefaultsKey) } + func saveAddSpaceAfterAccept(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.addSpaceAfterAcceptDefaultsKey) + } + func saveAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { userDefaults.set(Int(keyCode), forKey: Self.acceptanceKeyCodeDefaultsKey) userDefaults.set(Int(modifiers.rawValue), forKey: Self.acceptanceKeyModifiersDefaultsKey) diff --git a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift index 3a43751e..3e64bc8a 100644 --- a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift +++ b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift @@ -71,6 +71,15 @@ struct GeneralPaneView: View { ) } + Toggle(isOn: addSpaceAfterAcceptBinding) { + SettingsRowLabel( + title: "Add Space After Accepting", + description: "When accepting a suggestion finishes a word, also add a space so you can " + + "keep typing. Skipped when it already ends in punctuation or a space.", + systemImage: "space" + ) + } + Toggle(isOn: macroExpansionEnabledBinding) { SettingsRowLabel( title: "Inline Macros", @@ -182,6 +191,13 @@ struct GeneralPaneView: View { ) } + private var addSpaceAfterAcceptBinding: Binding { + Binding( + get: { suggestionSettings.addSpaceAfterAccept }, + set: { suggestionSettings.setAddSpaceAfterAccept($0) } + ) + } + private var macroExpansionEnabledBinding: Binding { Binding( get: { suggestionSettings.isMacroExpansionEnabled }, diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index aea0d28b..e20003be 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -16,6 +16,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case includeClipboardContext case allowMultiLine case acceptPunctuation + case addSpaceAfterAccept case inlineMacros case onboarding // Appearance @@ -89,6 +90,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .includeClipboardContext: return "Include Clipboard Context" case .allowMultiLine: return "Allow Multi-line Suggestions" case .acceptPunctuation: return "Accept Punctuation With Word" + case .addSpaceAfterAccept: return "Add Space After Accepting" case .inlineMacros: return "Inline Macros" case .onboarding: return "Onboarding" case .suggestionDisplay: return "Suggestion Display" @@ -152,6 +154,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .includeClipboardContext: return "doc.on.clipboard" case .allowMultiLine: return "text.alignleft" case .acceptPunctuation: return "textformat.abc" + case .addSpaceAfterAccept: return "space" case .inlineMacros: return "slash.circle" case .onboarding: return "graduationcap" case .suggestionDisplay: return "text.cursor" @@ -210,7 +213,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { var category: SettingsCategory { switch self { case .enableGlobally, .fastMode, .openAtLogin, .includeClipboardContext, - .allowMultiLine, .acceptPunctuation, .inlineMacros, .onboarding: + .allowMultiLine, .acceptPunctuation, .addSpaceAfterAccept, .inlineMacros, .onboarding: return .general case .suggestionDisplay, .showFieldIndicator, .showWordCount, .showKeyHint, .ghostTextColor, .ghostTextOpacity, .ghostTextSize: @@ -262,6 +265,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptPunctuation: return ["punctuation", "comma", "period", "accept", "trailing", "auto accept", "auto-accept", "space"] + case .addSpaceAfterAccept: + return ["space", "spacebar", "trailing space", "auto space", "add space", + "accept", "after accept", "whitespace", "gap", "separator"] case .inlineMacros: return ["macro", "macros", "math", "convert", "currency", "date", "random", "expansion", "slash", "snippet", "shortcut", "formula", "calculator"] diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 7db66f1b..553a54f9 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -238,6 +238,7 @@ enum CotabbyTestFixtures { focusPollIntervalMilliseconds: Int = 50, isMultiLineEnabled: Bool = false, autoAcceptTrailingPunctuation: Bool = true, + addSpaceAfterAccept: Bool = false, isFastModeEnabled: Bool = false, mirrorPreference: MirrorPreference = .auto, acceptanceGranularity: AcceptanceGranularity = .word, @@ -263,6 +264,7 @@ enum CotabbyTestFixtures { focusPollIntervalMilliseconds: focusPollIntervalMilliseconds, isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, + addSpaceAfterAccept: addSpaceAfterAccept, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity, diff --git a/CotabbyTests/SuggestionSessionReconcilerTests.swift b/CotabbyTests/SuggestionSessionReconcilerTests.swift index 4f133967..e46d4723 100644 --- a/CotabbyTests/SuggestionSessionReconcilerTests.swift +++ b/CotabbyTests/SuggestionSessionReconcilerTests.swift @@ -597,6 +597,58 @@ final class SuggestionSessionReconcilerTests: XCTestCase { ) } + func test_insertionChunkAppendingTrailingSpace_appendsAfterFinishedWord() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello"), + "hello " + ) + } + + func test_insertionChunkAppendingTrailingSpace_appendsAfterTrailingDigit() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("section 12"), + "section 12 " + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsWhenEndingInPunctuation() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("done."), + "done." + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("really?!"), + "really?!" + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("(yes)"), + "(yes)" + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsWhenAlreadyEndingInWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello "), + "hello " + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsForSpacelessScript() { + // CJK glyphs are letters, but their scripts never separate words with spaces, so a trailing + // space would be wrong. The space-less-script guard suppresses it. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("資料"), + "資料" + ) + } + + func test_insertionChunkAppendingTrailingSpace_leavesEmptyChunkUntouched() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(""), + "" + ) + } + func test_acceptedWordCount_countsOnlyTokensWithAlphanumerics() { let count = SuggestionSessionReconciler.acceptedWordCount( in: "hello, !!! world 123 --"