Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,7 @@ type_name:
max_length:
warning: 75
error: 75

type_body_length:
warning: 300
error: 500
17 changes: 17 additions & 0 deletions TextGrabber2/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
26 changes: 25 additions & 1 deletion TextGrabber2/Sources/App/App+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -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
}
}
}
}
5 changes: 5 additions & 0 deletions TextGrabber2/Sources/App/App+Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ extension App {
}

func updateServices() {
// User defined
servicesItem.submenu?.removeItems {
$0 is ServiceItem
}
Expand All @@ -36,5 +37,9 @@ extension App {

servicesItem.submenu?.insertItem(item, at: 0)
}

// System default
NSApp.servicesMenu = systemServicesItem.submenu
systemServicesItem.image = nil
}
}
14 changes: 14 additions & 0 deletions TextGrabber2/Sources/App/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ServiceManagement

@MainActor
final class App: NSObject, NSApplicationDelegate {
let servicesHost = ServicesHost()
var copyObserver: Task<Void, Never>?
var currentResult: Recognizer.ResultData?
var previewingFileURL: URL?
Expand Down Expand Up @@ -107,6 +108,13 @@ final class App: NSObject, NSApplicationDelegate {

lazy var servicesItem: NSMenuItem = {
let menu = NSMenu()
menu.delegate = self

Comment thread
cyanzhong marked this conversation as resolved.
if !Services.items.isEmpty {
menu.addItem(.separator())
}

menu.addItem(systemServicesItem)
menu.addItem(.separator())

menu.addItem(withTitle: Localized.menuTitleSettings) {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions TextGrabber2/Sources/Resources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
97 changes: 97 additions & 0 deletions TextGrabber2/Sources/ServicesHost.swift
Original file line number Diff line number Diff line change
@@ -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<String?, Never>?
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 }
}