@@ -26,18 +26,28 @@ final class TranslationCoordinator {
2626 private( set) var detectedLanguage : String ?
2727 private( set) var targetLanguage : String = " "
2828 private( set) var providerStates : [ String : ProviderState ] = [ : ]
29- private( set) var globalError : String ?
29+ /// Setting a non-nil error also auto-unpins, so an error never appears in a frozen pinned panel.
30+ private( set) var globalError : String ? {
31+ didSet {
32+ if globalError != nil { isPinned = false }
33+ }
34+ }
3035 private( set) var detectionResult : DetectionResult ?
3136 /// Snapshot of expanded provider slots for the current translation session.
3237 /// PopupView reads this instead of `registry.enabledSlots` to avoid recomputation during streaming.
3338 private( set) var activeSlots : [ any TranslationProvider ] = [ ]
3439 /// Monotonically increasing counter; increments each time `translate()` is called.
3540 /// Used by PopupView to reset `expandedProviders` for subsequent translations.
3641 private( set) var translationGeneration : Int = 0
42+ private( set) var copiedProviderID : String ?
43+ private( set) var copyFeedbackGeneration : Int = 0
44+ /// Single source of truth for popup pin state. PopupView toggles, PopupPanelController reads.
45+ var isPinned : Bool = false
3746
3847 let registry : TranslationProviderRegistry
3948 private let permissionManager : PermissionManager
4049 private var activeTasks : [ String : Task < Void , Never > ] = [ : ]
50+ private var copyFeedbackTask : Task < Void , Never > ?
4151
4252 init ( permissionManager: PermissionManager , registry: TranslationProviderRegistry ) {
4353 self . permissionManager = permissionManager
@@ -69,13 +79,21 @@ final class TranslationCoordinator {
6979
7080 /// Triggered by OCR shortcut: screen capture → OCR → translate.
7181 func ocrAndTranslate( ) async {
82+ guard permissionManager. isScreenRecordingGranted else {
83+ phase = . active
84+ sourceText = " "
85+ globalError = String ( localized: " Screen recording permission not granted. Open Settings to enable it. " )
86+ return
87+ }
88+
89+ let previousPhase = phase
7290 phase = . grabbing
7391
7492 do {
7593 let text = try await ScreenCaptureOCR . captureAndRecognize ( )
7694 translate ( text)
77- } catch is OCRError {
78- phase = . idle
95+ } catch OCRError . captureCancelled {
96+ phase = previousPhase
7997 } catch {
8098 phase = . active
8199 sourceText = " "
@@ -98,6 +116,7 @@ final class TranslationCoordinator {
98116 /// Reset state and enter input mode (empty source input for manual typing).
99117 func prepareInputMode( ) {
100118 cancelAll ( )
119+ clearCopyFeedback ( )
101120 globalError = nil
102121 sourceText = " "
103122 detectedLanguage = nil
@@ -120,6 +139,7 @@ final class TranslationCoordinator {
120139 }
121140
122141 cancelAll ( )
142+ clearCopyFeedback ( )
123143 globalError = nil
124144
125145 sourceText = trimmed
@@ -182,8 +202,27 @@ final class TranslationCoordinator {
182202 activeTasks [ provider. id] = task
183203 }
184204
205+ @discardableResult
206+ func copyResult( atDisplayIndex index: Int ) -> Bool {
207+ guard activeSlots. indices. contains ( index) else { return false }
208+ return copyResult ( forProviderID: activeSlots [ index] . id)
209+ }
210+
211+ @discardableResult
212+ func copyResult( forProviderID providerID: String ) -> Bool {
213+ guard let resultText = providerStates [ providerID] ? . copyableText,
214+ !resultText. trimmingCharacters ( in: . whitespacesAndNewlines) . isEmpty
215+ else { return false }
216+
217+ NSPasteboard . general. clearContents ( )
218+ guard NSPasteboard . general. setString ( resultText, forType: . string) else { return false }
219+ showCopyFeedback ( forProviderID: providerID)
220+ return true
221+ }
222+
185223 func dismiss( ) {
186224 cancelAll ( )
225+ clearCopyFeedback ( )
187226 phase = . idle
188227 sourceText = " "
189228 detectedLanguage = nil
@@ -192,6 +231,7 @@ final class TranslationCoordinator {
192231 globalError = nil
193232 detectionResult = nil
194233 activeSlots = [ ]
234+ isPinned = false
195235 }
196236
197237 // MARK: - Computed Helpers
@@ -260,6 +300,26 @@ final class TranslationCoordinator {
260300 activeTasks. removeAll ( )
261301 }
262302
303+ private func showCopyFeedback( forProviderID providerID: String ) {
304+ copyFeedbackTask? . cancel ( )
305+ copiedProviderID = providerID
306+ copyFeedbackGeneration += 1
307+
308+ copyFeedbackTask = Task { @MainActor [ weak self] in
309+ try ? await Task . sleep ( for: . milliseconds( 900 ) )
310+ guard !Task. isCancelled else { return }
311+ guard self ? . copiedProviderID == providerID else { return }
312+ self ? . copiedProviderID = nil
313+ self ? . copyFeedbackTask = nil
314+ }
315+ }
316+
317+ private func clearCopyFeedback( ) {
318+ copyFeedbackTask? . cancel ( )
319+ copyFeedbackTask = nil
320+ copiedProviderID = nil
321+ }
322+
263323 /// Build language hints (BCP 47 codes) based on user's target/source language preferences.
264324 /// Likely source languages are inferred from the target language for common translation pairs.
265325 private func buildLanguageHints( ) -> [ String : Double ] ? {
@@ -329,3 +389,16 @@ final class TranslationCoordinator {
329389 return preferred
330390 }
331391}
392+
393+ private extension TranslationCoordinator . ProviderState {
394+ var copyableText : String ? {
395+ switch self {
396+ case let . streaming( partial) :
397+ partial
398+ case let . completed( text) :
399+ text
400+ case . waiting, . translating, . error:
401+ nil
402+ }
403+ }
404+ }
0 commit comments