Skip to content

Commit 7c2453e

Browse files
feat: Add support for text snippets (#150)
* feat: Add support for text snippets * test: skip hardware-dependent AudioEngine tests in CI * fix: update as suggested in the review * fix: tests for snippets * fix: address review feedback on snippet persistence and polish - Auto-save sink now encodes the emitted array: @published fires on willSet, so re-reading self.snippets saved the previous state and left the latest change (e.g. deleting all snippets) unpersisted. Added testAutoSavePersistsDeletion to pin this down. - Store the expansion as entered (trim only for validation and the trigger) so intentional leading/trailing whitespace survives. - Make SnippetExpander final. - SnippetTests now clears the vocamac.snippets defaults key in setUp/tearDown so state cannot leak between tests; the round-trip test no longer clears snippets mid-test (that clear relied on the old one-save-behind behavior to pass). * fix: adapt snippets UI to the new settings shell Upstream replaced the settings TabView with a sidebar/detail shell, so the snippets page needed reworking rather than a straight port. - Expand snippets *after* DictationOutputFormatter, not before. Snippet expansions are literal text the user authored, so auto-capitalization must not rewrite them — expanding first turned an email snippet at the start of a sentence into "Me@example.com". Trigger matching is case-insensitive, so capitalizing the trigger first still matches. Pinned by testSnippetExpansionIsNotRewrittenByAutoCapitalize. - Rebuild SnippetsSettingsTab as a grouped Form to match every other detail page; the old List(.inset) plus bottom action bar was styled for the tab layout and looked foreign in the sidebar. - Give each row an explicit destructive remove button. Deletion previously relied on .onDelete with no selection binding, which has no affordance on macOS. Mirrors the existing Auto-Pause app list. - Drop the unused newTrigger/newExpansion state.
1 parent cc1f282 commit 7c2453e

10 files changed

Lines changed: 563 additions & 1 deletion

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ final class AppState: ObservableObject {
198198
selectedAudioDeviceName = device?.name ?? ""
199199
}
200200

201+
/// Custom text snippets for expansion
202+
@Published var snippets: [Snippet] = []
203+
201204
// MARK: - Services
202205

203206
let audioEngine: AudioRecording
@@ -208,6 +211,7 @@ final class AppState: ObservableObject {
208211
let soundManager: SoundPlaying
209212
let cursorOverlay: CursorOverlayManaging
210213
let statsManager: StatsManaging
214+
let snippetExpander: SnippetExpanding
211215
let updateChecker = UpdateChecker()
212216
let permissionManager: any PermissionManaging
213217

@@ -288,6 +292,7 @@ final class AppState: ObservableObject {
288292
soundManager: SoundPlaying = SoundManager(),
289293
cursorOverlay: CursorOverlayManaging,
290294
statsManager: StatsManaging,
295+
snippetExpander: SnippetExpanding = SnippetExpander(),
291296
permissionManager: (any PermissionManaging)? = nil,
292297
skipSystemIntegration: Bool = false
293298
) {
@@ -299,10 +304,12 @@ final class AppState: ObservableObject {
299304
self.soundManager = soundManager
300305
self.cursorOverlay = cursorOverlay
301306
self.statsManager = statsManager
307+
self.snippetExpander = snippetExpander
302308
self.permissionManager = permissionManager ?? PermissionManager(audioEngine: audioEngine, hotKeyManager: hotKeyManager)
303309
self.skipSystemIntegration = skipSystemIntegration
304310

305311
VocaLogger.info(.appState, "Initializing... id=\(ObjectIdentifier(self))")
312+
loadSnippets()
306313
if !skipSystemIntegration {
307314
syncLaunchAtLogin()
308315
}
@@ -529,6 +536,16 @@ final class AppState: ObservableObject {
529536
.sink { [weak self] _ in self?.objectWillChange.send() }
530537
.store(in: &cancellables)
531538

539+
// Auto-save snippets when changed. @Published emits on willSet, so
540+
// persist the emitted array — reading self.snippets here would save
541+
// the previous state and leave the latest change unsaved.
542+
$snippets
543+
.dropFirst() // skip the subscription replay of the just-loaded value
544+
.sink { [weak self] snippets in
545+
self?.saveSnippets(snippets)
546+
}
547+
.store(in: &cancellables)
548+
532549
// Check permissions
533550
checkPermissions()
534551

@@ -1023,11 +1040,17 @@ final class AppState: ObservableObject {
10231040

10241041
let trimmedText = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
10251042
if !trimmedText.isEmpty {
1026-
let polished = DictationOutputFormatter.apply(
1043+
// Polish first, expand second. Snippet expansions are literal
1044+
// text the user authored, so auto-capitalization must not
1045+
// rewrite them (an email snippet would become Me@example.com).
1046+
// Trigger matching is case-insensitive, so capitalizing the
1047+
// trigger beforehand still matches.
1048+
let polishedSource = DictationOutputFormatter.apply(
10271049
trimmedText,
10281050
autoCapitalize: autoCapitalize,
10291051
appendTrailingSpace: appendTrailingSpace
10301052
)
1053+
let polished = expandSnippets(in: polishedSource)
10311054
if injectResult {
10321055
textInjector.inject(
10331056
text: polished,
@@ -1576,4 +1599,33 @@ final class AppState: ObservableObject {
15761599
hasCompletedOnboarding = true
15771600
VocaLogger.info(.appState, "Onboarding completed")
15781601
}
1602+
1603+
// MARK: - Snippets Management
1604+
1605+
private func loadSnippets() {
1606+
if let data = UserDefaults.standard.data(forKey: "vocamac.snippets") {
1607+
do {
1608+
snippets = try JSONDecoder().decode([Snippet].self, from: data)
1609+
} catch {
1610+
VocaLogger.error(.appState, "Failed to decode snippets: \(error)")
1611+
}
1612+
}
1613+
}
1614+
1615+
func saveSnippets() {
1616+
saveSnippets(snippets)
1617+
}
1618+
1619+
private func saveSnippets(_ snippets: [Snippet]) {
1620+
do {
1621+
let encoded = try JSONEncoder().encode(snippets)
1622+
UserDefaults.standard.set(encoded, forKey: "vocamac.snippets")
1623+
} catch {
1624+
VocaLogger.error(.appState, "Failed to encode snippets: \(error)")
1625+
}
1626+
}
1627+
1628+
func expandSnippets(in text: String) -> String {
1629+
return snippetExpander.expand(in: text, using: snippets)
1630+
}
15791631
}

Sources/VocaMac/Models/SettingsPage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SwiftUI
99
/// Top-level settings topics shown in the left sidebar.
1010
enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
1111
case dictation
12+
case snippets
1213
case speechModel
1314
case audio
1415
case performance
@@ -22,6 +23,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
2223
var title: String {
2324
switch self {
2425
case .dictation: return "Dictation"
26+
case .snippets: return "Snippets"
2527
case .speechModel: return "Speech Model"
2628
case .audio: return "Audio"
2729
case .performance: return "Performance"
@@ -35,6 +37,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
3537
var systemImage: String {
3638
switch self {
3739
case .dictation: return "mic"
40+
case .snippets: return "text.quote"
3841
case .speechModel: return "brain"
3942
case .audio: return "waveform"
4043
case .performance: return "bolt.circle"

Sources/VocaMac/Models/SettingsSearchIndex.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ enum SettingsSearchIndex {
6363
keywords: ["capitalize", "output", "sentence", "punctuation"]
6464
),
6565

66+
// Snippets
67+
SettingsSearchEntry(
68+
id: "snippets",
69+
page: .snippets,
70+
title: "Custom Snippets",
71+
subtitle: "Replace spoken triggers with saved text",
72+
keywords: ["snippet", "shortcut", "expansion", "trigger", "replace", "macro", "abbreviation"]
73+
),
74+
6675
// Speech Model
6776
SettingsSearchEntry(
6877
id: "models",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Snippet.swift
2+
// VocaMac
3+
//
4+
// Model representing a custom text snippet with a trigger phrase and expansion text.
5+
6+
import Foundation
7+
8+
struct Snippet: Identifiable, Codable, Equatable {
9+
var id: UUID
10+
var trigger: String
11+
var expansion: String
12+
13+
init(id: UUID = UUID(), trigger: String = "", expansion: String = "") {
14+
self.id = id
15+
self.trigger = trigger
16+
self.expansion = expansion
17+
}
18+
}

Sources/VocaMac/Services/ServiceProtocols.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,9 @@ protocol StatsManaging: AnyObject {
170170
func recordTranscription(_ transcription: VocaTranscription)
171171
func resetStats()
172172
}
173+
174+
// MARK: - SnippetExpanding
175+
176+
protocol SnippetExpanding: AnyObject {
177+
func expand(in text: String, using snippets: [Snippet]) -> String
178+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// SnippetExpander.swift
2+
// VocaMac
3+
//
4+
// Pure logic for expanding text snippets with regex support.
5+
6+
import Foundation
7+
8+
final class SnippetExpander: SnippetExpanding {
9+
func expand(in text: String, using snippets: [Snippet]) -> String {
10+
guard !snippets.isEmpty else { return text }
11+
12+
// Sort snippets by trigger length descending to prioritize longer triggers
13+
let sortedSnippets = snippets
14+
.filter { !$0.trigger.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
15+
.sorted { $0.trigger.count > $1.trigger.count }
16+
17+
guard !sortedSnippets.isEmpty else { return text }
18+
19+
// Build a single combined regex to avoid cascading expansions
20+
// and handle different boundary requirements for word/non-word triggers.
21+
var patterns: [String] = []
22+
for snippet in sortedSnippets {
23+
let trigger = snippet.trigger.trimmingCharacters(in: .whitespacesAndNewlines)
24+
let escapedTrigger = NSRegularExpression.escapedPattern(for: trigger)
25+
26+
let prefix: String
27+
if let first = trigger.first, first.isWordCharacter {
28+
prefix = "\\b"
29+
} else {
30+
prefix = "(?<!\\S)"
31+
}
32+
33+
let suffix: String
34+
if let last = trigger.last, last.isWordCharacter {
35+
suffix = "\\b"
36+
} else {
37+
suffix = "(?!\\S)"
38+
}
39+
40+
// Capture each trigger in its own group to identify which one matched
41+
patterns.append("(\(prefix)\(escapedTrigger)\(suffix))")
42+
}
43+
44+
let combinedPattern = patterns.joined(separator: "|")
45+
46+
guard let regex = try? NSRegularExpression(pattern: combinedPattern, options: [.caseInsensitive]) else {
47+
return text
48+
}
49+
50+
let nsString = text as NSString
51+
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))
52+
53+
// Replace matches in reverse order to keep ranges valid
54+
var result = text
55+
for match in matches.reversed() {
56+
// Find which group matched (group 0 is the whole match, groups 1..N are our snippets)
57+
for i in 1...match.numberOfRanges - 1 {
58+
let range = match.range(at: i)
59+
if range.location != NSNotFound {
60+
let snippet = sortedSnippets[i - 1]
61+
62+
// Since we are going in reverse, we can just use string replacement on the range.
63+
if let resultRange = Range(match.range, in: result) {
64+
result.replaceSubrange(resultRange, with: snippet.expansion)
65+
}
66+
break
67+
}
68+
}
69+
}
70+
71+
return result
72+
}
73+
}
74+
75+
private extension Character {
76+
var isWordCharacter: Bool {
77+
return self.isLetter || self.isNumber || self == "_"
78+
}
79+
}

0 commit comments

Comments
 (0)