@@ -125,6 +125,9 @@ final class AppState: ObservableObject {
125125 @AppStorage ( PreferenceKey . autoPausePollInterval) var autoPausePollIntervalSeconds : Double = 5
126126 @AppStorage ( PreferenceKey . modelKeepAliveEnabled) var modelKeepAliveEnabled : Bool = false
127127 @AppStorage ( PreferenceKey . modelKeepAliveIdleTimeout) var modelKeepAliveIdleTimeoutSeconds : Double = 300
128+ @AppStorage ( PreferenceKey . writingStyleEnabled) var writingStyleEnabled : Bool = true
129+ @AppStorage ( PreferenceKey . writingStyleDefault) var writingStyleDefault : WritingStyle = . plain
130+ @AppStorage ( PreferenceKey . writingStyleCatalogSeeded) private var writingStyleCatalogSeeded : Bool = false
128131
129132 /// JSON-encoded `[AutoPauseAppEntry]` list (complex value not stored via `@AppStorage`).
130133 var autoPauseAppsJSON : String {
@@ -152,6 +155,23 @@ final class AppState: ObservableObject {
152155 }
153156 }
154157
158+ /// JSON-encoded `WritingStyleBindingStore` (complex value not stored via `@AppStorage`).
159+ var writingStyleBindingsJSON : String {
160+ get { UserDefaults . standard. string ( forKey: PreferenceKey . writingStyleBindings) ?? " " }
161+ set { UserDefaults . standard. set ( newValue, forKey: PreferenceKey . writingStyleBindings) }
162+ }
163+
164+ /// Per-app writing style rules. A corrupt payload decodes to an empty list
165+ /// so dictation falls back to the default style rather than failing.
166+ var writingStyleBindings : [ AppStyleBinding ] {
167+ get { WritingStyleBindingStore . decode ( json: writingStyleBindingsJSON) . bindings }
168+ set {
169+ writingStyleBindingsJSON = WritingStyleBindingStore ( bindings: newValue) . encodedJSON ( )
170+ refreshActiveWritingStyle ( )
171+ objectWillChange. send ( )
172+ }
173+ }
174+
155175 /// True while a configured auto-pause app is running and dictation is blocked.
156176 @Published var isAutoPaused : Bool = false
157177
@@ -168,6 +188,10 @@ final class AppState: ObservableObject {
168188 /// negotiating the route.
169189 private var pendingStopDuringStart : PendingStopKind ?
170190
191+ /// Frontmost app captured when recording started. Used only when the app
192+ /// in front at injection time is VocaMac itself (Settings has focus).
193+ private var pendingTargetApp : RunningAppSnapshot ?
194+
171195 private enum PendingStopKind {
172196 /// Push-to-talk released: keep whatever the engine managed to capture.
173197 case transcribe
@@ -181,6 +205,15 @@ final class AppState: ObservableObject {
181205 /// Display name of the app that triggered the current auto-pause, if any.
182206 @Published var autoPauseTriggerDisplayName : String ?
183207
208+ /// Style that would be used if the user dictated right now. Drives the
209+ /// menu bar indicator; refreshed when the popover appears and after every
210+ /// dictation, never on a timer.
211+ @Published private( set) var activeWritingStyle : ResolvedWritingStyle = . plain
212+
213+ /// Style used by the Settings preview and by Test Dictation, where the
214+ /// frontmost app is VocaMac's own window.
215+ @Published var settingsPreviewStyle : WritingStyle = . plain
216+
184217 /// Approximate process RSS (MB) sampled just before the last unload.
185218 @Published var processMemoryBeforeUnloadMB : Double ?
186219
@@ -210,6 +243,8 @@ final class AppState: ObservableObject {
210243 let statsManager : StatsManaging
211244 let updateChecker = UpdateChecker ( )
212245 let permissionManager : any PermissionManaging
246+ /// Identifies the app that will receive injected text.
247+ let frontmostAppResolver : FrontmostAppResolving
213248
214249 /// Polls configured apps and pauses dictation while they run.
215250 let autoPauseMonitor = AutoPauseMonitor ( )
@@ -289,6 +324,7 @@ final class AppState: ObservableObject {
289324 cursorOverlay: CursorOverlayManaging ,
290325 statsManager: StatsManaging ,
291326 permissionManager: ( any PermissionManaging ) ? = nil ,
327+ frontmostAppResolver: FrontmostAppResolving = FrontmostAppResolver ( ) ,
292328 skipSystemIntegration: Bool = false
293329 ) {
294330 self . audioEngine = audioEngine
@@ -299,6 +335,7 @@ final class AppState: ObservableObject {
299335 self . soundManager = soundManager
300336 self . cursorOverlay = cursorOverlay
301337 self . statsManager = statsManager
338+ self . frontmostAppResolver = frontmostAppResolver
302339 self . permissionManager = permissionManager ?? PermissionManager ( audioEngine: audioEngine, hotKeyManager: hotKeyManager)
303340 self . skipSystemIntegration = skipSystemIntegration
304341
@@ -402,6 +439,12 @@ final class AppState: ObservableObject {
402439 // Detect system capabilities
403440 systemCapabilities = SystemInfo . detect ( )
404441
442+ // One-shot writing style seeding. Skipped in tests and the CLI, which
443+ // must not touch LaunchServices.
444+ if !skipSystemIntegration {
445+ seedWritingStyleCatalogIfNeeded ( )
446+ }
447+
405448 // Get WhisperKit's device recommendation.
406449 // WhisperKit's `.default` may not be in the supported list for some
407450 // devices. If so, fall back to the best supported model instead.
@@ -823,6 +866,101 @@ final class AppState: ObservableObject {
823866 VocaLogger . debug ( . appState, " Hotkey configuration synced (keyCode= \( hotKeyCode) , modifiers= \( hotKeyModifiers. rawValue) , mode= \( activationMode. rawValue) ) " )
824867 }
825868
869+ // MARK: - Writing Styles
870+
871+ /// Resolve the style for a target app using the current preferences.
872+ func resolveWritingStyle( for target: RunningAppSnapshot ? ) -> ResolvedWritingStyle {
873+ WritingStyleResolver . resolve (
874+ target: target,
875+ bindings: writingStyleBindings,
876+ defaultStyle: writingStyleDefault,
877+ isEnabled: writingStyleEnabled
878+ )
879+ }
880+
881+ /// Recompute `activeWritingStyle` from whatever app is in front now.
882+ ///
883+ /// Called when the menu bar popover appears and after settings changes —
884+ /// deliberately not on a timer.
885+ func refreshActiveWritingStyle( ) {
886+ activeWritingStyle = resolveWritingStyle ( for: frontmostAppResolver. currentFrontmostApp ( ) )
887+ }
888+
889+ /// Bind the frontmost app to a style, replacing any existing rule for it.
890+ ///
891+ /// This is the menu bar's one-tap fix for "that came out wrong".
892+ @discardableResult
893+ func bindFrontmostApp( to style: WritingStyle ) -> String ? {
894+ guard let target = frontmostAppResolver. currentFrontmostApp ( ) else {
895+ VocaLogger . warning ( . appState, " Cannot bind writing style: no frontmost app " )
896+ return nil
897+ }
898+ var bindings = writingStyleBindings
899+ bindings. removeAll { $0. matches ( target) }
900+ bindings. append ( AppStyleBinding . from ( snapshot: target, style: style) )
901+ writingStyleBindings = bindings
902+ VocaLogger . info ( . appState, " Bound \( target. displayName) to writing style ' \( style. rawValue) ' " )
903+ return target. displayName
904+ }
905+
906+ /// Add the suggested rules for apps installed on this Mac, once.
907+ ///
908+ /// Runs on first launch after upgrading. Existing bindings are never
909+ /// touched, and the marker means a user who deletes every rule does not
910+ /// get them back on the next launch.
911+ ///
912+ /// The LaunchServices lookups behind `suggestionsForInstalledApps` are one
913+ /// per catalog entry, so they run off the launch path — a menu bar app must
914+ /// not stall its first paint on a few dozen disk-backed queries.
915+ func seedWritingStyleCatalogIfNeeded( ) {
916+ guard !writingStyleCatalogSeeded else { return }
917+ // Claim the marker on the main actor before detaching, so a second
918+ // call cannot start a duplicate seed.
919+ writingStyleCatalogSeeded = true
920+
921+ let running = AppIdentityMatching . workspaceRunningApps ( )
922+ Task . detached ( priority: . utility) { [ weak self] in
923+ let suggestions = WritingStyleCatalog . suggestionsForInstalledApps ( running: running)
924+ guard let self else { return }
925+ await self . applyWritingStyleSeed ( suggestions)
926+ }
927+ }
928+
929+ /// Merge a computed seed into the binding list. Split out so tests can
930+ /// supply suggestions directly instead of querying LaunchServices.
931+ func applyWritingStyleSeed( _ suggestions: [ WritingStyleCatalog . Suggestion ] ) {
932+ guard !suggestions. isEmpty else {
933+ VocaLogger . info ( . appState, " No writing style suggestions matched installed apps " )
934+ return
935+ }
936+ writingStyleBindings = WritingStyleCatalog . merging ( writingStyleBindings, with: suggestions)
937+ VocaLogger . info ( . appState, " Seeded \( suggestions. count) writing style rules " )
938+ }
939+
940+ /// Add every suggestion for an installed app that is not already bound.
941+ /// Returns how many rules were added.
942+ @discardableResult
943+ func addSuggestedWritingStyles( ) -> Int {
944+ let existing = writingStyleBindings
945+ let merged = WritingStyleCatalog . merging (
946+ existing,
947+ with: WritingStyleCatalog . suggestionsForInstalledApps ( )
948+ )
949+ writingStyleBindings = merged
950+ return merged. count - existing. count
951+ }
952+
953+ /// Format sample text the way the given style would, for the Settings
954+ /// preview. Uses the same engine as the real pipeline.
955+ func writingStylePreview( _ sample: String , style: WritingStyle ) -> String {
956+ WritingStyleEngine . format (
957+ sample. trimmingCharacters ( in: . whitespacesAndNewlines) ,
958+ rules: style. defaultRules,
959+ globalAutoCapitalize: autoCapitalize,
960+ globalTrailingSpace: appendTrailingSpace
961+ )
962+ }
963+
826964 // MARK: - Force Recovery
827965
828966 /// Forcibly reset the entire recording pipeline to idle state.
@@ -871,6 +1009,11 @@ final class AppState: ObservableObject {
8711009 return
8721010 }
8731011
1012+ // Snapshot the target app now. Injection re-reads the frontmost app —
1013+ // that is what actually receives the text — and only falls back to this
1014+ // when VocaMac itself is in front at that point.
1015+ pendingTargetApp = frontmostAppResolver. currentFrontmostApp ( )
1016+
8741017 guard appStatus == . idle else {
8751018 // If stuck in .processing or .error for too long, force recovery
8761019 // so the user can start a fresh recording.
@@ -1023,19 +1166,38 @@ final class AppState: ObservableObject {
10231166
10241167 let trimmedText = result. text. trimmingCharacters ( in: . whitespacesAndNewlines)
10251168 if !trimmedText. isEmpty {
1026- let polished = DictationOutputFormatter . apply (
1027- trimmedText,
1028- autoCapitalize: autoCapitalize,
1029- appendTrailingSpace: appendTrailingSpace
1030- )
10311169 if injectResult {
1170+ // Resolve against the app in front right now: that is where
1171+ // the text lands. `pendingTargetApp` covers the case where
1172+ // VocaMac's own window took focus mid-dictation.
1173+ let target = frontmostAppResolver. currentFrontmostApp ( ) ?? pendingTargetApp
1174+ let resolved = resolveWritingStyle ( for: target)
1175+ activeWritingStyle = resolved
1176+
1177+ let polished = WritingStyleEngine . format (
1178+ trimmedText,
1179+ rules: resolved. rules,
1180+ globalAutoCapitalize: autoCapitalize,
1181+ globalTrailingSpace: appendTrailingSpace
1182+ )
1183+ VocaLogger . debug (
1184+ . appState,
1185+ " Writing style ' \( resolved. style. rawValue) ' applied for \( resolved. matchedAppName ?? " default " ) "
1186+ )
10321187 textInjector. inject (
10331188 text: polished,
10341189 preserveClipboard: preserveClipboard
10351190 )
10361191 } else {
1037- // Settings Test Dictation: show only in the sidebar footer.
1038- settingsTestResultText = polished
1192+ // Settings Test Dictation: the frontmost app is VocaMac's
1193+ // own window, so preview against the style the user picked
1194+ // in Settings instead of resolving from the workspace.
1195+ settingsTestResultText = WritingStyleEngine . format (
1196+ trimmedText,
1197+ rules: settingsPreviewStyle. defaultRules,
1198+ globalAutoCapitalize: autoCapitalize,
1199+ globalTrailingSpace: appendTrailingSpace
1200+ )
10391201 }
10401202 } else {
10411203 VocaLogger . info ( . appState, " Transcription produced no usable text (silence or blank audio) " )
0 commit comments