diff --git a/speaktype/Services/DictionaryService.swift b/speaktype/Services/DictionaryService.swift new file mode 100644 index 0000000..1541da6 --- /dev/null +++ b/speaktype/Services/DictionaryService.swift @@ -0,0 +1,193 @@ +import Combine +import Foundation +import SwiftUI // for RangeReplaceableCollection.remove(atOffsets:) + +/// A single dictionary rule. +/// +/// A rule maps a spoken `trigger` (what the recognizer hears) to the +/// `replacement` text that gets inserted instead. It powers two use cases: +/// +/// 1. **Snippets / text expansion** — say "my email" and get +/// `roy.sanhik@gmail.com` inserted. The trigger is a phrase, the +/// replacement is arbitrary text. +/// 2. **Spelling / vocabulary fixes** — the model consistently mishears a +/// name or term (e.g. "figjam" → "FigJam"); the rule rewrites it after +/// transcription. +/// +/// Replacements run fully offline as a post-processing pass on the final +/// transcript, so they work identically for every engine (Whisper, Parakeet). +struct DictionaryEntry: Identifiable, Codable, Hashable { + var id: UUID = UUID() + /// What you say / what the model transcribes. + var trigger: String + /// The text inserted in place of the trigger. Leave empty to delete the trigger. + var replacement: String + /// When false the rule is kept but not applied. + var isEnabled: Bool = true + /// Only match when the trigger stands alone as whole words (recommended). + var matchWholeWord: Bool = true + + var trimmedTrigger: String { + trigger.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +/// Stores the user's dictionary rules and applies them to transcripts. +/// +/// Rules are persisted as JSON in `UserDefaults` (mirroring `HistoryService`). +/// The transcription path reads the rules through the thread-safe static +/// `apply(to:)`, which decodes straight from `UserDefaults` so it never +/// touches the `@Published` state across threads. +final class DictionaryService: ObservableObject { + static let shared = DictionaryService() + + @Published private(set) var entries: [DictionaryEntry] = [] + + private static let saveKey = "dictionary_entries" + private static let migrationFlagKey = "dictionaryDidMigrateAutoEditRules" + private static let legacyRulesKey = "customReplacementRules" + + private init() { + migrateLegacyRulesIfNeeded() + loadEntries() + } + + // MARK: - CRUD + + func addEntry(trigger: String, replacement: String, matchWholeWord: Bool = true) { + let entry = DictionaryEntry( + trigger: trigger, + replacement: replacement, + matchWholeWord: matchWholeWord + ) + entries.insert(entry, at: 0) + save() + } + + func update(_ entry: DictionaryEntry) { + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return } + entries[index] = entry + save() + } + + func setEnabled(_ isEnabled: Bool, for id: UUID) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { return } + entries[index].isEnabled = isEnabled + save() + } + + func delete(id: UUID) { + entries.removeAll { $0.id == id } + save() + } + + func delete(at offsets: IndexSet) { + entries.remove(atOffsets: offsets) + save() + } + + // MARK: - Persistence + + private func save() { + if let encoded = try? JSONEncoder().encode(entries) { + UserDefaults.standard.set(encoded, forKey: Self.saveKey) + } + } + + private func loadEntries() { + guard let data = UserDefaults.standard.data(forKey: Self.saveKey), + let decoded = try? JSONDecoder().decode([DictionaryEntry].self, from: data) + else { return } + entries = decoded + } + + /// One-time import of the old freeform `customReplacementRules` string + /// (one `from => to` rule per line) into structured entries so existing + /// users keep their replacements. + private func migrateLegacyRulesIfNeeded() { + let defaults = UserDefaults.standard + guard !defaults.bool(forKey: Self.migrationFlagKey) else { return } + defer { defaults.set(true, forKey: Self.migrationFlagKey) } + + // Nothing already stored under the new key and there are legacy rules. + guard defaults.data(forKey: Self.saveKey) == nil else { return } + let raw = defaults.string(forKey: Self.legacyRulesKey) ?? "" + guard !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let migrated: [DictionaryEntry] = raw + .split(whereSeparator: \.isNewline) + .compactMap { rawLine in + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return nil } + + for separator in ["=>", "->", "="] { + let parts = line.components(separatedBy: separator) + guard parts.count >= 2 else { continue } + let source = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let replacement = parts[1...].joined(separator: separator) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty else { return nil } + return DictionaryEntry(trigger: source, replacement: replacement) + } + return nil + } + + guard !migrated.isEmpty else { return } + if let encoded = try? JSONEncoder().encode(migrated) { + defaults.set(encoded, forKey: Self.saveKey) + } + } + + // MARK: - Applying rules to a transcript + + /// Apply every enabled rule to `text`. Safe to call from any thread — + /// it reads the rules straight from `UserDefaults`. + static func apply(to text: String) -> String { + guard !text.isEmpty, + let data = UserDefaults.standard.data(forKey: saveKey), + let entries = try? JSONDecoder().decode([DictionaryEntry].self, from: data), + !entries.isEmpty + else { return text } + + var result = text + for entry in entries where entry.isEnabled { + let trigger = entry.trimmedTrigger + guard !trigger.isEmpty else { continue } + result = replace( + trigger, + with: entry.replacement, + in: result, + matchWholeWord: entry.matchWholeWord + ) + } + return result + } + + /// Word-boundary-aware, case-insensitive replacement. Spaces in the trigger + /// match any run of whitespace, so multi-word phrases survive minor spacing + /// differences. Matching is always case-insensitive because the recognizer, + /// not the user, decides how a spoken phrase is capitalized. + private static func replace(_ source: String, with replacement: String, in text: String, + matchWholeWord: Bool) -> String { + let escapedSource = NSRegularExpression.escapedPattern(for: source) + .replacingOccurrences(of: " ", with: #"\s+"#) + + let needsLeadingBoundary = matchWholeWord + && (source.first?.isLetter == true || source.first?.isNumber == true) + let needsTrailingBoundary = matchWholeWord + && (source.last?.isLetter == true || source.last?.isNumber == true) + let pattern = + "\(needsLeadingBoundary ? #"\b"# : "")\(escapedSource)\(needsTrailingBoundary ? #"\b"# : "")" + + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + else { + return text + } + + let range = NSRange(text.startIndex..., in: text) + // Escape the user-supplied replacement so `$` / `\` stay literal. + let template = NSRegularExpression.escapedTemplate(for: replacement) + return regex.stringByReplacingMatches( + in: text, options: [], range: range, withTemplate: template) + } +} diff --git a/speaktype/Services/Transcription/TranscriptionManager.swift b/speaktype/Services/Transcription/TranscriptionManager.swift index 84a0a68..80bb9c0 100644 --- a/speaktype/Services/Transcription/TranscriptionManager.swift +++ b/speaktype/Services/Transcription/TranscriptionManager.swift @@ -89,9 +89,14 @@ class TranscriptionManager { } /// Transcribe an audio file with the currently active engine. + /// + /// The raw engine output is passed through the user's dictionary rules so + /// custom replacements and spoken snippets apply uniformly regardless of + /// which backend produced the text. func transcribe(audioFile: URL, language: String = "auto") async throws -> String { let kind = AIModel.engineKind(for: currentModelVariant) - return try await engine(for: kind).transcribe(audioFile: audioFile, language: language) + let text = try await engine(for: kind).transcribe(audioFile: audioFile, language: language) + return DictionaryService.apply(to: text) } } diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 3e8ce44..dd959a9 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -6,7 +6,6 @@ class WhisperService { // Shared singleton instance - use this everywhere static let shared = WhisperService() private static let autoEditEnabledKey = "enableAutoEdit" - private static let customReplacementRulesKey = "customReplacementRules" private static let placeholderPatterns = [ #"\[(?:BLANK_AUDIO|SILENCE)\]"#, #"<\|nospeech\|>"#, @@ -327,11 +326,11 @@ class WhisperService { return normalized.trimmingCharacters(in: .whitespacesAndNewlines) } - private struct AutoEditRule { - let source: String - let replacement: String - } - + /// Filler-word removal + punctuation tidy, gated by the "Auto Edit" toggle. + /// + /// Custom word replacements and spoken snippets are applied separately by + /// `DictionaryService` in `TranscriptionManager`, so they run once for + /// every engine (not just Whisper) and independently of this toggle. private static func applyAutoEdit(to text: String) -> String { guard UserDefaults.standard.bool(forKey: autoEditEnabledKey) else { return text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -343,10 +342,6 @@ class WhisperService { options: .regularExpression ) - for rule in customReplacementRules() { - edited = replace(rule.source, with: rule.replacement, in: edited) - } - edited = edited.replacingOccurrences( of: #"\s+([,.;:!?])"#, with: "$1", @@ -359,48 +354,4 @@ class WhisperService { ) return edited.trimmingCharacters(in: .whitespacesAndNewlines) } - - private static func customReplacementRules() -> [AutoEditRule] { - let rawRules = UserDefaults.standard.string(forKey: customReplacementRulesKey) ?? "" - - return rawRules - .split(whereSeparator: \.isNewline) - .compactMap { rawLine in - let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.isEmpty else { return nil } - - for separator in ["=>", "->", "="] { - let parts = line.components(separatedBy: separator) - guard parts.count >= 2 else { continue } - - let source = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let replacement = parts[1...].joined(separator: separator) - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard !source.isEmpty else { return nil } - return AutoEditRule(source: source, replacement: replacement) - } - - return nil - } - } - - private static func replace(_ source: String, with replacement: String, in text: String) -> String { - let escapedSource = NSRegularExpression.escapedPattern(for: source) - .replacingOccurrences(of: " ", with: #"\s+"#) - let needsLeadingBoundary = source.first?.isLetter == true || source.first?.isNumber == true - let needsTrailingBoundary = source.last?.isLetter == true || source.last?.isNumber == true - let pattern = - "\(needsLeadingBoundary ? #"\b"# : "")\(escapedSource)\(needsTrailingBoundary ? #"\b"# : "")" - - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { - return text - } - - let range = NSRange(text.startIndex..., in: text) - // Escape the user-supplied replacement so `$` / `\` are treated literally, - // not as regex template tokens. - let template = NSRegularExpression.escapedTemplate(for: replacement) - return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: template) - } } diff --git a/speaktype/Views/MainView.swift b/speaktype/Views/MainView.swift index edf4f84..48332a5 100644 --- a/speaktype/Views/MainView.swift +++ b/speaktype/Views/MainView.swift @@ -43,6 +43,8 @@ struct MainView: View { TranscribeAudioView() case .history: HistoryView() + case .dictionary: + DictionaryView() case .statistics: StatisticsView() case .aiModels: diff --git a/speaktype/Views/Screens/Dictionary/DictionaryView.swift b/speaktype/Views/Screens/Dictionary/DictionaryView.swift new file mode 100644 index 0000000..7390220 --- /dev/null +++ b/speaktype/Views/Screens/Dictionary/DictionaryView.swift @@ -0,0 +1,427 @@ +import SwiftUI + +/// Manage custom word replacements and spoken snippets. +/// +/// A rule rewrites the trigger phrase in the final transcript with the +/// replacement text — say "my email" and get your address, or fix a term the +/// model keeps mishearing. Rules run fully offline for every engine. +struct DictionaryView: View { + @StateObject private var dictionary = DictionaryService.shared + @State private var editorEntry: DictionaryEntry? + @State private var isPresentingEditor = false + @State private var entryPendingDeletion: DictionaryEntry? + + var body: some View { + ScrollView { + VStack(spacing: 24) { + header + + explainer + + if dictionary.entries.isEmpty { + emptyState + } else { + VStack(spacing: 12) { + ForEach(dictionary.entries) { entry in + DictionaryRuleCard( + entry: entry, + onToggle: { dictionary.setEnabled($0, for: entry.id) }, + onEdit: { present(entry) }, + onDelete: { entryPendingDeletion = entry } + ) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + } + } + .sheet(isPresented: $isPresentingEditor) { + DictionaryEntryEditor(entry: editorEntry) { result in + if dictionary.entries.contains(where: { $0.id == result.id }) { + dictionary.update(result) + } else { + dictionary.addEntry( + trigger: result.trigger, + replacement: result.replacement, + matchWholeWord: result.matchWholeWord + ) + } + } + } + .alert( + "Delete Rule?", + isPresented: Binding( + get: { entryPendingDeletion != nil }, + set: { if !$0 { entryPendingDeletion = nil } } + ), + presenting: entryPendingDeletion + ) { entry in + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + dictionary.delete(id: entry.id) + entryPendingDeletion = nil + } + } message: { entry in + Text("“\(entry.trimmedTrigger)” will no longer be replaced.") + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text("Dictionary") + .font(Typography.displayLarge) + .foregroundStyle(Color.textPrimary) + + if !dictionary.entries.isEmpty { + Text("\(dictionary.entries.count) rule\(dictionary.entries.count == 1 ? "" : "s")") + .font(Typography.bodySmall) + .foregroundStyle(Color.textSecondary) + } + } + + Spacer() + + Button(action: { present(nil) }) { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + Text("Add Rule") + } + .font(Typography.labelMedium) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentBlue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + .padding(.top, 20) + } + + // MARK: - Explainer + + private var explainer: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "wand.and.stars") + .font(.system(size: 14)) + .foregroundStyle(Color.accentBlue) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text("Turn what you say into the text you want") + .font(Typography.labelLarge) + .foregroundStyle(Color.textPrimary) + Text( + "Say a trigger like “my email” and SpeakType inserts your real address. Or fix a name the model keeps mishearing. Everything runs offline on your Mac." + ) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(16) + .background(Color.accentBlue.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 24) + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 20) { + Image(systemName: "character.book.closed") + .font(.system(size: 52)) + .foregroundStyle(Color.textMuted.opacity(0.4)) + + VStack(spacing: 8) { + Text("No rules yet") + .font(Typography.displaySmall) + .foregroundStyle(Color.textPrimary) + + Text("Add your first rule to replace a spoken phrase with any text.") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + } + + Button(action: { present(nil) }) { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + Text("Add Rule") + } + .font(Typography.labelMedium) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.accentBlue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + // MARK: - Helpers + + private func present(_ entry: DictionaryEntry?) { + editorEntry = entry + isPresentingEditor = true + } +} + +// MARK: - Rule Card + +private struct DictionaryRuleCard: View { + let entry: DictionaryEntry + let onToggle: (Bool) -> Void + let onEdit: () -> Void + let onDelete: () -> Void + @State private var isHovered = false + + private var replacementDisplay: String { + entry.replacement.isEmpty ? "(deleted)" : entry.replacement + } + + var body: some View { + HStack(spacing: 16) { + // Trigger → replacement + HStack(spacing: 12) { + Text(entry.trimmedTrigger) + .font(Typography.bodyMedium.weight(.medium)) + .foregroundStyle(Color.textPrimary) + .lineLimit(1) + + Image(systemName: "arrow.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.textMuted) + + Text(replacementDisplay) + .font(Typography.bodyMedium) + .foregroundStyle(entry.replacement.isEmpty ? Color.textMuted : Color.textSecondary) + .lineLimit(1) + .italic(entry.replacement.isEmpty) + } + .opacity(entry.isEnabled ? 1 : 0.45) + + Spacer(minLength: 12) + + // Attribute chips + HStack(spacing: 6) { + if !entry.matchWholeWord { + RuleChip(text: "partial") + } + } + .opacity(entry.isEnabled ? 1 : 0.45) + + // Actions (revealed on hover) + HStack(spacing: 4) { + if isHovered { + IconButton(systemName: "pencil", action: onEdit) + IconButton(systemName: "trash", action: onDelete) + } + } + .frame(width: isHovered ? 56 : 0, alignment: .trailing) + .clipped() + + Toggle("", isOn: Binding(get: { entry.isEnabled }, set: onToggle)) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(Color.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isHovered ? Color.border : Color.border.opacity(0.5), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onEdit) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.12)) { isHovered = hovering } + } + } +} + +private struct RuleChip: View { + let text: String + var body: some View { + Text(text) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.bgHover) + .clipShape(Capsule()) + } +} + +private struct IconButton: View { + let systemName: String + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 12)) + .foregroundStyle(isHovered ? Color.textPrimary : Color.textMuted) + .frame(width: 24, height: 24) + .background(isHovered ? Color.bgHover : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +// MARK: - Editor Sheet + +private struct DictionaryEntryEditor: View { + /// nil when adding a brand-new rule. + let entry: DictionaryEntry? + let onSave: (DictionaryEntry) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var trigger: String + @State private var replacement: String + @State private var matchWholeWord: Bool + + init(entry: DictionaryEntry?, onSave: @escaping (DictionaryEntry) -> Void) { + self.entry = entry + self.onSave = onSave + _trigger = State(initialValue: entry?.trigger ?? "") + _replacement = State(initialValue: entry?.replacement ?? "") + _matchWholeWord = State(initialValue: entry?.matchWholeWord ?? true) + } + + private var isValid: Bool { + !trigger.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(entry == nil ? "New Rule" : "Edit Rule") + .font(Typography.displaySmall) + .foregroundStyle(Color.textPrimary) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 18) { + field( + title: "When I say", + subtitle: "The word or phrase to listen for" + ) { + TextField("", text: $trigger, prompt: Text("my email")) + .textFieldStyle(.plain) + } + + field( + title: "Replace with", + subtitle: "Any text to insert. Leave empty to remove the phrase." + ) { + TextField( + "", text: $replacement, prompt: Text("john.doe@example.com"), + axis: .vertical + ) + .textFieldStyle(.plain) + .lineLimit(1...4) + } + + Divider() + + Toggle(isOn: $matchWholeWord) { + VStack(alignment: .leading, spacing: 2) { + Text("Match whole words only") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + Text("Won't fire inside longer words.") + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + } + } + .toggleStyle(.switch) + } + + Spacer(minLength: 24) + + HStack(spacing: 12) { + Spacer() + + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(Typography.labelMedium) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.bgHover) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button("Save") { save() } + .buttonStyle(.plain) + .font(Typography.labelMedium) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(isValid ? Color.accentBlue : Color.accentBlue.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .disabled(!isValid) + } + } + .padding(28) + .frame(width: 440) + .background(Color.bgContent) + } + + @ViewBuilder + private func field( + title: String, subtitle: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(Typography.labelLarge) + .foregroundStyle(Color.textPrimary) + Text(subtitle) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + } + + ZStack(alignment: .topLeading) { + content() + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .background(Color.bgHover) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.border.opacity(0.6), lineWidth: 1) + ) + } + } + + private func save() { + guard isValid else { return } + var result = entry ?? DictionaryEntry(trigger: "", replacement: "") + result.trigger = trigger.trimmingCharacters(in: .whitespacesAndNewlines) + result.replacement = replacement + result.matchWholeWord = matchWholeWord + onSave(result) + dismiss() + } +} diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index 0885567..691276c 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -95,7 +95,6 @@ struct GeneralSettingsTab: View { @AppStorage("transcriptionLanguage") private var transcriptionLanguage: String = "auto" @AppStorage("recentTranscriptionLanguages") private var recentLanguagesString: String = "" @AppStorage("enableAutoEdit") private var enableAutoEdit: Bool = false - @AppStorage("customReplacementRules") private var customReplacementRules: String = "" private var recentLanguageCodes: [String] { recentLanguagesString.split(separator: ",").map(String.init).filter { !$0.isEmpty } @@ -257,38 +256,28 @@ struct GeneralSettingsTab: View { .font(Typography.captionSmall) .foregroundStyle(Color.textMuted) - VStack(alignment: .leading, spacing: 8) { - Text("Custom replacements") - .font(Typography.bodyMedium) - .foregroundStyle(Color.textPrimary) - - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 10) - .fill(Color.bgHover) - - if customReplacementRules.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text("teh => the\nspeak type => SpeakType\nuh huh =>") - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(Color.textMuted) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .allowsHitTesting(false) - } + Divider() - TextEditor(text: $customReplacementRules) - .font(.system(size: 12, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - .frame(minHeight: 110) - .opacity(enableAutoEdit ? 1.0 : 0.65) + HStack(alignment: .top, spacing: 10) { + Image(systemName: "character.book.closed") + .font(.system(size: 13)) + .foregroundStyle(Color.textMuted) + .padding(.top, 2) - Text("One rule per line using `from => to`. Leave the right side blank to delete a phrase.") + VStack(alignment: .leading, spacing: 3) { + Text("Custom replacements & snippets") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + Text( + "Word replacements and spoken snippets (say “my email” → your address) now live in the Dictionary tab in the sidebar. They apply on every model, always on." + ) .font(Typography.captionSmall) .foregroundStyle(Color.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) } - .disabled(!enableAutoEdit) } } diff --git a/speaktype/Views/SidebarView.swift b/speaktype/Views/SidebarView.swift index ca7bc75..0e4ac0a 100644 --- a/speaktype/Views/SidebarView.swift +++ b/speaktype/Views/SidebarView.swift @@ -179,6 +179,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case dashboard = "Dashboard" case transcribeAudio = "Transcribe Audio" case history = "History" + case dictionary = "Dictionary" case statistics = "Statistics" case aiModels = "AI Models" case settings = "Settings" @@ -190,6 +191,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .dashboard: return "square.grid.2x2" case .transcribeAudio: return "waveform" case .history: return "doc.text" + case .dictionary: return "character.book.closed" case .statistics: return "chart.bar" case .aiModels: return "cpu" case .settings: return "gearshape"