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.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
D5CAF3B590E5EC2AFC72E57A /* VisualContextStartCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */; };
D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; };
DD7FA343F1C21C4569F6D181 /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; };
DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */; };
DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */; };
E17CAA453B1F534D284F0D89 /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; };
E313639E71AE1374D2B9A956 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; };
Expand Down Expand Up @@ -348,6 +349,7 @@
E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = "<group>"; };
E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = "<group>"; };
E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = "<group>"; };
E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = "<group>"; };
E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTracker.swift; sourceTree = "<group>"; };
E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = "<group>"; };
EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabels.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -503,6 +505,7 @@
6E1171BBC9CB74DB623C5E8B /* Components */ = {
isa = PBXGroup;
children = (
E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */,
19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */,
);
path = Components;
Expand Down Expand Up @@ -830,6 +833,7 @@
30F3F2B6D13CD583136CD787 /* AXHelper.swift in Sources */,
D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */,
78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */,
DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */,
0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */,
26E0331E9E2F92FAE531BDEE /* ActivationIndicatorController.swift in Sources */,
0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */,
Expand Down
14 changes: 11 additions & 3 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,21 @@ extension SuggestionCoordinator {
)
}

let preparation = fullText
? interactionState.prepareFullAcceptance(from: rawContext, overlayState: overlayState)
: interactionState.prepareAcceptance(
// `acceptEntireSuggestion` forces the full-acceptance path regardless of granularity so the
// dedicated full-accept key stays a per-press override. `acceptCurrentSuggestion` honors
// the user-selected granularity for the primary accept key.
let primaryGranularity = settingsSnapshot.acceptanceGranularity
let preparation: SuggestionAcceptancePreparation
if fullText || primaryGranularity == .full {
preparation = interactionState.prepareFullAcceptance(from: rawContext, overlayState: overlayState)
} else {
preparation = interactionState.prepareAcceptance(
from: rawContext,
overlayState: overlayState,
granularity: primaryGranularity,
autoAcceptTrailingPunctuation: settingsSnapshot.autoAcceptTrailingPunctuation
)
}

let liveContext: FocusedInputContext
let sessionForAcceptance: ActiveSuggestionSession
Expand Down
14 changes: 14 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ struct DisabledApplicationRule: Codable, Equatable, Identifiable, Sendable {
var id: String { bundleIdentifier }
}

/// How much of a buffered suggestion the primary accept key takes per press. The dedicated
/// full-accept key always takes the entire remaining tail regardless of this setting.
enum AcceptanceGranularity: String, CaseIterable, Codable, Sendable {
/// One word (with the existing trailing-punctuation policy applied per chunk).
case word
/// Words accumulated until a sentence terminator (`.`, `!`, `?`, `\n`) or the tail runs out.
case phrase
/// The entire remaining suggestion at once — same outcome as the dedicated full-accept key.
case full
}

/// A compact snapshot of the autocomplete settings the coordinator actually needs at generation
/// time. Keeping this as a value type makes change detection simple and deterministic.
struct SuggestionSettingsSnapshot: Equatable, Sendable {
Expand Down Expand Up @@ -76,4 +87,7 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable {
/// 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
/// How much of the buffered suggestion the primary accept key takes per press. Read once per
/// accept call so a mid-press setting change can't strand a partially-handled press.
let acceptanceGranularity: AcceptanceGranularity
}
78 changes: 52 additions & 26 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var fullAcceptanceKeyCode: CGKeyCode
@Published private(set) var fullAcceptanceKeyModifiers: ShortcutModifierMask
@Published private(set) var fullAcceptanceKeyLabel: String
@Published private(set) var acceptanceGranularity: AcceptanceGranularity
private let userDefaults: UserDefaults

private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled"
Expand Down Expand Up @@ -67,6 +68,7 @@ final class SuggestionSettingsModel: ObservableObject {
private static let fullAcceptanceKeyCodeDefaultsKey = "cotabbyFullAcceptanceKeyCode"
private static let fullAcceptanceKeyModifiersDefaultsKey = "cotabbyFullAcceptanceKeyModifiers"
private static let fullAcceptanceKeyLabelDefaultsKey = "cotabbyFullAcceptanceKeyLabel"
private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity"

static let defaultAcceptanceKeyCode: CGKeyCode = 48
static let defaultAcceptanceKeyLabel = "Tab"
Expand Down Expand Up @@ -200,6 +202,13 @@ final class SuggestionSettingsModel: ObservableObject {
)
let resolvedFullAcceptanceKeyLabel = userDefaults.string(forKey: Self.fullAcceptanceKeyLabelDefaultsKey)
?? Self.defaultFullAcceptanceKeyLabel
// Default `.word` preserves the pre-feature behavior for existing installs that have no
// value persisted yet. Invalid persisted values fall back to `.word` rather than crashing
// so a hand-edited UserDefault can't strand the user.
let resolvedAcceptanceGranularity = userDefaults
.string(forKey: Self.acceptanceGranularityDefaultsKey)
.flatMap(AcceptanceGranularity.init(rawValue:))
?? .word

isGloballyEnabled = resolvedGloballyEnabled
disabledAppRules = resolvedDisabledAppRules
Expand All @@ -225,6 +234,7 @@ final class SuggestionSettingsModel: ObservableObject {
fullAcceptanceKeyCode = resolvedFullAcceptanceKeyCode
fullAcceptanceKeyModifiers = resolvedFullAcceptanceKeyModifiers
fullAcceptanceKeyLabel = resolvedFullAcceptanceKeyLabel
acceptanceGranularity = resolvedAcceptanceGranularity

userDefaults.set(resolvedGloballyEnabled, forKey: Self.isGloballyEnabledDefaultsKey)
persistDisabledAppRules(resolvedDisabledAppRules)
Expand Down Expand Up @@ -253,6 +263,7 @@ final class SuggestionSettingsModel: ObservableObject {
forKey: Self.fullAcceptanceKeyModifiersDefaultsKey
)
userDefaults.set(resolvedFullAcceptanceKeyLabel, forKey: Self.fullAcceptanceKeyLabelDefaultsKey)
userDefaults.set(resolvedAcceptanceGranularity.rawValue, forKey: Self.acceptanceGranularityDefaultsKey)

// The custom indicator icon feature was removed; scrub any previously-persisted PNG so
// users who picked one in an older build get the default cat icon back automatically.
Expand All @@ -279,7 +290,8 @@ final class SuggestionSettingsModel: ObservableObject {
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity
)
}

Expand Down Expand Up @@ -344,6 +356,14 @@ final class SuggestionSettingsModel: ObservableObject {
userDefaults.set(enabled, forKey: Self.autoAcceptTrailingPunctuationDefaultsKey)
}

func setAcceptanceGranularity(_ granularity: AcceptanceGranularity) {
guard acceptanceGranularity != granularity else {
return
}
acceptanceGranularity = granularity
userDefaults.set(granularity.rawValue, forKey: Self.acceptanceGranularityDefaultsKey)
}

func setDebounceMilliseconds(_ value: Int) {
let clamped = max(10, min(500, value))
guard debounceMilliseconds != clamped else {
Expand Down Expand Up @@ -778,7 +798,10 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
// 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(
//
// The outer CombineLatest4 is at the cap, so `$acceptanceGranularity` is layered above it
// via a second CombineLatest to avoid restructuring the existing groupings.
let primary = Publishers.CombineLatest4(
Publishers.CombineLatest4(
$isGloballyEnabled,
$disabledAppRules,
Expand All @@ -794,29 +817,32 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
$autoAcceptTrailingPunctuation
)
)
.map { combinedSettings, presentationToggles, profile, timing in
let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles
let (userName, customRules, responseLanguages) = profile
let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing
return SuggestionSettingsSnapshot(
isGloballyEnabled: globallyEnabled,
disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)),
selectedEngine: engine,
selectedWordCountPreset: wordCountPreset,
isClipboardContextEnabled: clipboardContextEnabled,
userName: userName,
customRules: customRules,
responseLanguages: responseLanguages,
debounceMilliseconds: debounce,
focusPollIntervalMilliseconds: focusPoll,
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
isFastModeEnabled: fastModeEnabled,
mirrorPreference: mirrorPreference
)
}
.removeDuplicates()
.eraseToAnyPublisher()
return Publishers.CombineLatest(primary, $acceptanceGranularity)
.map { primaryTuple, granularity in
let (combinedSettings, presentationToggles, profile, timing) = primaryTuple
let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles
let (userName, customRules, responseLanguages) = profile
let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing
return SuggestionSettingsSnapshot(
isGloballyEnabled: globallyEnabled,
disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)),
selectedEngine: engine,
selectedWordCountPreset: wordCountPreset,
isClipboardContextEnabled: clipboardContextEnabled,
userName: userName,
customRules: customRules,
responseLanguages: responseLanguages,
debounceMilliseconds: debounce,
focusPollIntervalMilliseconds: focusPoll,
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
isFastModeEnabled: fastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity
)
}
.removeDuplicates()
.eraseToAnyPublisher()
}
}
29 changes: 25 additions & 4 deletions Cotabby/Services/Suggestion/SuggestionInteractionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,41 @@ final class SuggestionInteractionState {
/// Validates whether the current stored session can be accepted from the latest live AX state.
/// The returned value gives the coordinator the exact chunk to insert and the context it should
/// use for diagnostics and overlay updates.
///
/// `granularity` selects between word-by-word and phrase-by-phrase acceptance. `.full` is
/// rejected here because the coordinator routes full accepts to `prepareFullAcceptance`, which
/// keeps a distinct invalid-reason message for the dedicated full-accept key.
func prepareAcceptance(
from snapshot: FocusedInputSnapshot,
overlayState: OverlayState,
granularity: AcceptanceGranularity,
autoAcceptTrailingPunctuation: Bool = true
) -> SuggestionAcceptancePreparation {
let validated = validateSessionForAcceptance(from: snapshot, overlayState: overlayState)
guard let (liveContext, session) = validated.session else {
return .invalid(validated.failureReason ?? "Key passed through.")
}

let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(
from: session.remainingText,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation
)
let chunk: String
switch granularity {
case .word:
chunk = SuggestionSessionReconciler.nextAcceptanceChunk(
from: session.remainingText,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation
)
case .phrase:
chunk = SuggestionSessionReconciler.nextAcceptancePhrase(
from: session.remainingText,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation
)
case .full:
// The coordinator routes full accepts to `prepareFullAcceptance`, so `.full` should never
// reach here. Trap in debug, but degrade gracefully in release by passing the key through
// rather than crashing a shipping process — matching the other `.invalid` branches here.
assertionFailure("prepareAcceptance should not receive .full — route to prepareFullAcceptance instead")
return .invalid("Key passed through because .full granularity was routed incorrectly.")
}

guard !chunk.isEmpty else {
return .invalid("Key passed through because no remaining suggestion chunk was available.")
}
Expand Down
Loading