diff --git a/.swiftlint.yml b/.swiftlint.yml index b0106f1..409bacf 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -454,3 +454,7 @@ type_name: max_length: warning: 75 error: 75 + +type_body_length: + warning: 300 + error: 500 diff --git a/TextGrabber2/Resources/Localizable.xcstrings b/TextGrabber2/Resources/Localizable.xcstrings index 3e92a00..9f10ee9 100644 --- a/TextGrabber2/Resources/Localizable.xcstrings +++ b/TextGrabber2/Resources/Localizable.xcstrings @@ -775,6 +775,23 @@ } } }, + "System Services" : { + "comment" : "[Menu] Open the default system services menu", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统服务" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "系統服務" + } + } + } + }, "TextGrabber2" : { "localizations" : { "zh-Hans" : { diff --git a/TextGrabber2/Sources/App/App+Menu.swift b/TextGrabber2/Sources/App/App+Menu.swift index 2ded16e..7430b9f 100644 --- a/TextGrabber2/Sources/App/App+Menu.swift +++ b/TextGrabber2/Sources/App/App+Menu.swift @@ -58,6 +58,10 @@ extension App { extension App: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { + guard menu == mainMenu else { + return + } + if KeyBindings.items.hasValue { menu.enumerateDescendants { item in if let keyBinding = (KeyBindings.items.first { $0.actionName == item.title }) { @@ -73,7 +77,27 @@ extension App: NSMenuDelegate { } } + func menuWillOpen(_ menu: NSMenu) { + guard menu == servicesItem.submenu else { + return + } + + // Host the text so NSApp.servicesMenu can be populated + servicesHost.activate(text: currentResult?.spacesJoined ?? "") + } + func menuDidClose(_ menu: NSMenu) { - isMenuVisible = false + if menu == mainMenu { + isMenuVisible = false + } + + // Tear down on any menu close; menuDidClose isn't reliable for the submenu + // (see the outside-click fallback in addEventMonitors), and this is a no-op + // unless the host is active + Task { + if let updatedText = await servicesHost.scheduleTeardown() { + NSPasteboard.general.string = updatedText + } + } } } diff --git a/TextGrabber2/Sources/App/App+Services.swift b/TextGrabber2/Sources/App/App+Services.swift index 33c1587..d6a00d1 100644 --- a/TextGrabber2/Sources/App/App+Services.swift +++ b/TextGrabber2/Sources/App/App+Services.swift @@ -15,6 +15,7 @@ extension App { } func updateServices() { + // User defined servicesItem.submenu?.removeItems { $0 is ServiceItem } @@ -36,5 +37,9 @@ extension App { servicesItem.submenu?.insertItem(item, at: 0) } + + // System default + NSApp.servicesMenu = systemServicesItem.submenu + systemServicesItem.image = nil } } diff --git a/TextGrabber2/Sources/App/App.swift b/TextGrabber2/Sources/App/App.swift index 0feca4b..9f15c72 100644 --- a/TextGrabber2/Sources/App/App.swift +++ b/TextGrabber2/Sources/App/App.swift @@ -10,6 +10,7 @@ import ServiceManagement @MainActor final class App: NSObject, NSApplicationDelegate { + let servicesHost = ServicesHost() var copyObserver: Task? var currentResult: Recognizer.ResultData? var previewingFileURL: URL? @@ -107,6 +108,13 @@ final class App: NSObject, NSApplicationDelegate { lazy var servicesItem: NSMenuItem = { let menu = NSMenu() + menu.delegate = self + + if !Services.items.isEmpty { + menu.addItem(.separator()) + } + + menu.addItem(systemServicesItem) menu.addItem(.separator()) menu.addItem(withTitle: Localized.menuTitleSettings) { @@ -122,6 +130,12 @@ final class App: NSObject, NSApplicationDelegate { return item }() + lazy var systemServicesItem: NSMenuItem = { + let item = NSMenuItem(title: Localized.menuTitleSystemServices) + item.submenu = NSMenu() + return item + }() + lazy var clipboardToolsItem: NSMenuItem = { let menu = NSMenu() menu.autoenablesItems = false diff --git a/TextGrabber2/Sources/Resources.swift b/TextGrabber2/Sources/Resources.swift index f47fb80..de4c8fd 100644 --- a/TextGrabber2/Sources/Resources.swift +++ b/TextGrabber2/Sources/Resources.swift @@ -23,6 +23,7 @@ enum Localized { static let menuTitleHowToUse = String(localized: "How to Use?", comment: "[Menu] How to use the app") static let menuTitleHowToSetUp = String(localized: "How to Set Up?", comment: "[Menu] How to set up the app") static let menuTitleServices = String(localized: "Services", comment: "[Menu] System services menu") + static let menuTitleSystemServices = String(localized: "System Services", comment: "[Menu] Open the default system services menu") static let menuTitleSettings = String(localized: "Settings…", comment: "[Menu] Configure system services") static let menuTitleUserManual = String(localized: "User Manual", comment: "[Menu] Open the wiki for system services") static let menuTitleClipboardTools = String(localized: "Clipboard Tools", comment: "[Menu] Clipboard tools") diff --git a/TextGrabber2/Sources/ServicesHost.swift b/TextGrabber2/Sources/ServicesHost.swift new file mode 100644 index 0000000..8e68da8 --- /dev/null +++ b/TextGrabber2/Sources/ServicesHost.swift @@ -0,0 +1,97 @@ +// +// ServicesHost.swift +// TextGrabber2 +// +// Created by cyan on 2026/7/1. +// + +import AppKit + +/** + Off-screen key window that vends the current text to `NSApp.servicesMenu`, + which a status-bar menu has no responder chain to populate otherwise. + */ +@MainActor +final class ServicesHost { + private lazy var textView: NSTextView = { + let view = NSTextView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + view.isHidden = true + view.isRichText = false + + return view + }() + + private lazy var window: NSWindow = { + let window = ServicesPanel( + contentRect: CGRect(x: -10000, y: -10000, width: 1, height: 1), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + window.contentView = textView + window.isReleasedWhenClosed = false + window.hidesOnDeactivate = false + window.isExcludedFromWindowsMenu = true + window.collectionBehavior = [.transient, .ignoresCycle] + return window + }() + + private var isActive = false + private var teardownTask: Task? + private var previousText = "" + + func activate(text: String) { + isActive = true + teardownTask?.cancel() + teardownTask = nil + + previousText = text + textView.string = text + textView.selectAll(nil) + + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(textView) + } + + /// Tears down after a delay, cancellable so a clicked service can run first. + func scheduleTeardown() async -> String? { + guard isActive else { + return nil + } + + teardownTask?.cancel() + teardownTask = Task { [weak self] () -> String? in + try? await Task.sleep(for: .seconds(0.5)) + guard let self, !Task.isCancelled else { + return nil + } + + return deactivate() + } + + return await teardownTask?.value + } + + /// Returns text if a return-type service changed it, otherwise nil. + private func deactivate() -> String? { + guard isActive else { + return nil + } + + isActive = false + teardownTask?.cancel() + teardownTask = nil + + let currentText = textView.string + window.orderOut(nil) + + return currentText != previousText ? currentText : nil + } +} + +// MARK: - Private + +private final class ServicesPanel: NSPanel { + override var canBecomeKey: Bool { true } +}