Skip to content

Commit e539c21

Browse files
yusixianqingx2JimssM
authored
Release v0.14.0: popup shortcuts & pin window (#69)
* feat(Popup): improve translation popup shortcuts (#65) * feat(Popup): enhance input focus handling and localization updates * chore: bump version to 0.14.0 * feat: 增加翻译窗口右上角固定窗口置顶按钮 (#64) * Add pinned popup dismiss hook * Track pinned popup state * Add popup pin button * Refine popup pin placement * Hide popup pin outside translation view * fix: unpin error popup * fix: handle missing screen recording permission --------- Co-authored-by: yusixian <i@cosine.ren> * refactor(Popup): centralize pin state and tighten async handoff - Move `isPinned` into TranslationCoordinator as single source of truth; PopupView toggles, PopupPanelController reads. Remove duplicated @State and onPinnedChange callback plumbing. - Auto-unpin via `globalError` didSet so error states never appear in a frozen pinned panel. - Move pin button from ZStack overlay into the language bar HStack to stop it from occluding the source input's top-right corner. - Defer first-responder reassignment in `consumePendingSourceInputFocus` via `Task { @mainactor }` to avoid re-entering AppKit during `viewDidMoveToWindow`. - Cancelable `pulseTask` in ProviderResultCard with onDisappear cleanup to prevent overlapping copy-pulse animations on rapid copies. --------- Co-authored-by: Qing Guo <qingeekk@qq.com> Co-authored-by: JimssM <hydraliskm91@gmail.com>
1 parent c393d3f commit e539c21

10 files changed

Lines changed: 455 additions & 72 deletions

Project.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ let project = Project(
4949
"CODE_SIGN_STYLE": "$(MOEPEEK_CODE_SIGN_STYLE)",
5050
"CODE_SIGN_IDENTITY": "$(MOEPEEK_CODE_SIGN_IDENTITY)",
5151
"DEVELOPMENT_TEAM": "$(MOEPEEK_DEVELOPMENT_TEAM)",
52-
"MARKETING_VERSION": "0.13.1",
52+
"MARKETING_VERSION": "0.14.0",
5353
"CURRENT_PROJECT_VERSION": "1",
5454
],
5555
configurations: [

Resources/Localizable.xcstrings

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
}
4848
},
4949
"↵ Translate · ⇧↵ Newline" : {
50+
"extractionState" : "stale",
5051
"localizations" : {
5152
"zh-Hans" : {
5253
"stringUnit" : {
@@ -56,6 +57,16 @@
5657
}
5758
}
5859
},
60+
"↵ Translate · ⌘↵ Copy & Close · ⇧↵ Newline" : {
61+
"localizations" : {
62+
"zh-Hans" : {
63+
"stringUnit" : {
64+
"state" : "translated",
65+
"value" : "↵ 翻译 · ⌘↵ 复制并关闭 · ⇧↵ 换行"
66+
}
67+
}
68+
}
69+
},
5970
"About" : {
6071
"extractionState" : "stale",
6172
"localizations" : {
@@ -1466,6 +1477,17 @@
14661477
}
14671478
}
14681479
},
1480+
"Pin Popup" : {
1481+
"extractionState" : "stale",
1482+
"localizations" : {
1483+
"zh-Hans" : {
1484+
"stringUnit" : {
1485+
"state" : "translated",
1486+
"value" : "钉住窗口"
1487+
}
1488+
}
1489+
}
1490+
},
14691491
"Please enter a valid Base URL (starting with http:// or https://) and API Key" : {
14701492
"extractionState" : "stale",
14711493
"localizations" : {
@@ -1778,6 +1800,17 @@
17781800
}
17791801
}
17801802
},
1803+
"Screen recording permission not granted. Open Settings to enable it." : {
1804+
"extractionState" : "stale",
1805+
"localizations" : {
1806+
"zh-Hans" : {
1807+
"stringUnit" : {
1808+
"state" : "translated",
1809+
"value" : "屏幕录制权限未授权。请前往设置中开启。"
1810+
}
1811+
}
1812+
}
1813+
},
17811814
"Screenshot OCR" : {
17821815
"extractionState" : "stale",
17831816
"localizations" : {
@@ -2285,6 +2318,17 @@
22852318
}
22862319
}
22872320
},
2321+
"Unpin Popup" : {
2322+
"extractionState" : "stale",
2323+
"localizations" : {
2324+
"zh-Hans" : {
2325+
"stringUnit" : {
2326+
"state" : "translated",
2327+
"value" : "取消钉住窗口"
2328+
}
2329+
}
2330+
}
2331+
},
22882332
"Unsupported" : {
22892333
"extractionState" : "stale",
22902334
"localizations" : {

Sources/Core/TranslationCoordinator.swift

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

Sources/UI/PopupDismissMonitor.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import AppKit
44
@MainActor
55
final class PopupDismissMonitor {
66
private let panel: NSPanel
7+
private let isPinned: @MainActor () -> Bool
78
private let onDismiss: @MainActor () -> Void
89

910
nonisolated(unsafe) private var globalClickMonitor: Any?
1011
nonisolated(unsafe) private var globalKeyMonitor: Any?
1112
nonisolated(unsafe) private var localClickMonitor: Any?
1213
nonisolated(unsafe) private var localKeyMonitor: Any?
1314

14-
init(panel: NSPanel, onDismiss: @escaping @MainActor () -> Void) {
15+
init(
16+
panel: NSPanel,
17+
isPinned: @escaping @MainActor () -> Bool = { false },
18+
onDismiss: @escaping @MainActor () -> Void
19+
) {
1520
self.panel = panel
21+
self.isPinned = isPinned
1622
self.onDismiss = onDismiss
1723
}
1824

@@ -22,7 +28,7 @@ final class PopupDismissMonitor {
2228
matching: [.leftMouseDown, .rightMouseDown]
2329
) { [weak self] _ in
2430
Task { @MainActor in
25-
self?.onDismiss()
31+
self?.dismissForOutsideClick()
2632
}
2733
}
2834

@@ -43,12 +49,12 @@ final class PopupDismissMonitor {
4349
let clickLocation = event.locationInWindow
4450
if event.window !== self.panel {
4551
Task { @MainActor in
46-
self.onDismiss()
52+
self.dismissForOutsideClick()
4753
}
4854
} else if let contentView = self.panel.contentView,
4955
!contentView.frame.contains(clickLocation) {
5056
Task { @MainActor in
51-
self.onDismiss()
57+
self.dismissForOutsideClick()
5258
}
5359
}
5460
return event
@@ -65,6 +71,11 @@ final class PopupDismissMonitor {
6571
}
6672
}
6773

74+
private func dismissForOutsideClick() {
75+
guard !isPinned() else { return }
76+
onDismiss()
77+
}
78+
6879
func stop() {
6980
if let monitor = globalClickMonitor { NSEvent.removeMonitor(monitor) }
7081
if let monitor = globalKeyMonitor { NSEvent.removeMonitor(monitor) }

0 commit comments

Comments
 (0)