Files
Termind/TFM/UI/Components/NativeContextMenu.swift

336 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import SwiftUI
import TermindApplication
/// A destructive action inside a SwiftUI menu, using the system’s image visibility.
struct DestructiveMenuButton: View {
let title: LocalizedStringResource
let systemImage: String
let action: () -> Void
init(
_ title: LocalizedStringResource,
systemImage: String = MenuSymbol.delete,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.action = action
}
var body: some View {
Button(role: .destructive, action: action) {
Label {
Text(title)
} icon: {
Image(systemName: systemImage)
}
}
}
}
/// Keeps Show History and connection-record images visible in SwiftUI-generated menus.
///
/// SwiftUI does not expose `NSMenuItem.preferredImageVisibility`. A named image
/// survives SwiftUI's conversion to `NSMenuItem`, giving this observer a narrow,
/// semantic marker that does not depend on English titles or SF Symbol artwork.
@MainActor
final class MenuImageVisibilityController: NSObject {
static let shared = MenuImageVisibilityController()
private static let markerPrefix = "termind.menu.always-visible."
private var isStarted = false
private var notificationCenter: NotificationCenter?
static func markedImage(systemName: String) -> NSImage {
let markerName = NSImage.Name(markerPrefix + systemName)
if let image = NSImage(named: markerName) { return image }
guard let image = NSImage(systemSymbolName: systemName, accessibilityDescription: nil) else {
return NSImage()
}
_ = image.setName(markerName)
return image
}
func start(notificationCenter: NotificationCenter = .default) {
guard !isStarted else { return }
isStarted = true
self.notificationCenter = notificationCenter
for name in [
NSMenu.didBeginTrackingNotification,
NSMenu.didAddItemNotification,
NSMenu.didChangeItemNotification,
] {
notificationCenter.addObserver(
self,
selector: #selector(menuContentsChanged(_:)),
name: name,
object: nil
)
}
}
static func apply(to menu: NSMenu) {
guard #available(macOS 27.0, *) else { return }
for item in menu.items {
if item.image?.name()?.hasPrefix(markerPrefix) == true,
item.preferredImageVisibility != .visible
{
item.preferredImageVisibility = .visible
}
if let submenu = item.submenu {
apply(to: submenu)
}
}
}
@objc private func menuContentsChanged(_ notification: Notification) {
guard let menu = notification.object as? NSMenu else { return }
Self.apply(to: menu)
}
deinit {
notificationCenter?.removeObserver(self)
}
}
/// One row of a hand-built native menu.
///
/// A value type rather than an `NSMenuItem` so callers never own a target, keep
/// a menu alive, or worry about when AppKit asks for it. Shared by the titlebar
/// search field's magnifier menu and by the blank-area menus in the sidebar and
/// on the manager pages.
struct NativeMenuEntry {
private var literalTitle: String
private var titleResource: LocalizedStringResource?
var title: String {
get { resolvedTitle(locale: .current) }
set {
literalTitle = newValue
titleResource = nil
}
}
/// The SF Symbol beside the title, from `MenuSymbol`. AppKit fills this in
/// for standard actions and leaves ours empty, so every verb names its own.
var symbol: String?
var image: NSImage?
var isOn = false
var isEnabled = true
var children: [NativeMenuEntry] = []
var action: () -> Void = {}
init(
title: String, symbol: String? = nil, image: NSImage? = nil,
isOn: Bool = false,
isEnabled: Bool = true, children: [NativeMenuEntry] = [], action: @escaping () -> Void = {}
) {
literalTitle = title
self.symbol = symbol
self.image = image
self.isOn = isOn
self.isEnabled = isEnabled
self.children = children
self.action = action
}
init(
title: LocalizedStringResource, symbol: String? = nil, image: NSImage? = nil,
isOn: Bool = false,
isEnabled: Bool = true, children: [NativeMenuEntry] = [], action: @escaping () -> Void = {}
) {
self.init(
title: "", symbol: symbol, image: image,
isOn: isOn, isEnabled: isEnabled, children: children, action: action)
titleResource = title
}
static let separator = NativeMenuEntry(title: "", isEnabled: false)
var isSeparator: Bool { titleResource == nil && literalTitle.isEmpty }
private func resolvedTitle(locale: Locale) -> String {
titleResource.map { AppLocalization.resolve($0, locale: locale) } ?? literalTitle
}
static func menu(_ entries: [NativeMenuEntry], locale: Locale = .current) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
fill(menu, with: entries, locale: locale)
return menu
}
/// Replaces a long-lived menu's contents. `NSSearchField.searchMenuTemplate`
/// is assigned once and refreshed in place — swapping the menu object while
/// it is open takes it out from under itself.
static func fill(_ menu: NSMenu, with entries: [NativeMenuEntry], locale: Locale = .current) {
menu.removeAllItems()
menu.autoenablesItems = false
for entry in entries {
menu.addItem(entry.menuItem(locale: locale))
}
}
func menuItem(locale: Locale = .current) -> NSMenuItem {
guard !isSeparator else { return .separator() }
let item = NSMenuItem(title: resolvedTitle(locale: locale), action: nil, keyEquivalent: "")
item.image =
image
?? symbol.flatMap {
NSImage(systemSymbolName: $0, accessibilityDescription: nil)
}
if children.isEmpty {
let target = NativeMenuActionTarget(action)
item.representedObject = target
item.target = target
item.action = #selector(NativeMenuActionTarget.invoke)
item.state = isOn ? .on : .off
item.isEnabled = isEnabled
} else {
item.submenu = NativeMenuEntry.menu(children, locale: locale)
item.isEnabled = isEnabled
}
return item
}
}
/// Keeps a menu item's closure alive for exactly as long as the item.
///
/// `NSMenuItem.target` is unowned, so a temporary coordinator would be gone by
/// the time the user let go of the mouse. Parking it in `representedObject`
/// gives it the item's lifetime and nothing else's.
private final class NativeMenuActionTarget: NSObject {
private let action: () -> Void
init(_ action: @escaping () -> Void) {
self.action = action
}
@objc func invoke() { action() }
}
/// A row's right-click menu, with the selection outline left to the caller.
///
/// SwiftUI's `contextMenu` draws its own source rectangle from the frame of the
/// view it is attached to, which in a sidebar row is the row *plus* the
/// breathing edge around it — a box visibly larger than the card it belongs to.
/// AppKit draws no source rectangle at all, so the row can outline itself
/// exactly on the filled shape.
struct NativeRowContextMenu: NSViewRepresentable {
let entries: () -> [NativeMenuEntry]
/// True while the menu is up, so the row can show it is the one acted on.
@Binding var isPresented: Bool
func makeNSView(context: Context) -> RowMenuView {
RowMenuView()
}
func updateNSView(_ view: RowMenuView, context: Context) {
view.entries = entries
view.locale = context.environment.locale
view.setPresented = { isPresented = $0 }
}
@MainActor
final class RowMenuView: NSView, NSMenuDelegate {
var locale = Locale.current
var entries: () -> [NativeMenuEntry] = { [] }
var setPresented: (Bool) -> Void = { _ in }
override func hitTest(_ point: NSPoint) -> NSView? {
guard let event = NSApp.currentEvent, Self.isContextMenuEvent(event)
else { return nil }
return super.hitTest(point) == nil ? nil : self
}
static func isContextMenuEvent(_ event: NSEvent) -> Bool {
switch event.type {
case .rightMouseDown, .rightMouseUp: true
case .leftMouseDown, .leftMouseUp: event.modifierFlags.contains(.control)
default: false
}
}
override func mouseDown(with event: NSEvent) {
if Self.isContextMenuEvent(event) {
rightMouseDown(with: event)
} else {
super.mouseDown(with: event)
}
}
override func rightMouseDown(with event: NSEvent) {
guard let menu = makeMenu() else { return }
// Keep this menu independent of subsequent SwiftUI row updates.
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
func makeMenu() -> NSMenu? {
let items = entries()
guard !items.isEmpty else { return nil }
let menu = NativeMenuEntry.menu(items, locale: locale)
menu.delegate = self
return menu
}
func menuWillOpen(_ menu: NSMenu) { setPresented(true) }
func menuDidClose(_ menu: NSMenu) { setPresented(false) }
}
}
/// A right-click-only overlay for the space *between* items.
///
/// Two things make this AppKit rather than a SwiftUI `contextMenu`. It has to
/// know where the pointer is, so a click that landed on a card can be declined
/// and handled by that card's own menu; and SwiftUI paints a source rectangle
/// for the whole view it is attached to, which on a page-sized container is the
/// whole page lighting up.
///
/// `hitTest` claims nothing but right-mouse events, and only where `claims` says
/// the point is empty, so every other event reaches the views underneath.
struct NativeBlankAreaMenu: NSViewRepresentable {
/// Whether the blank-area menu owns this point, in the host's own flipped
/// coordinates. Return false over an item that has a menu of its own.
var claims: (CGPoint) -> Bool = { _ in true }
/// Built fresh per click, so enabled states reflect the current selection.
let entries: (CGPoint) -> [NativeMenuEntry]
func makeNSView(context: Context) -> BlankAreaMenuView {
BlankAreaMenuView()
}
func updateNSView(_ view: BlankAreaMenuView, context: Context) {
view.claims = claims
view.entries = entries
view.locale = context.environment.locale
}
@MainActor
final class BlankAreaMenuView: NSView {
var locale = Locale.current
var claims: (CGPoint) -> Bool = { _ in true }
var entries: (CGPoint) -> [NativeMenuEntry] = { _ in [] }
override var isFlipped: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? {
guard let event = NSApp.currentEvent,
event.type == .rightMouseDown || event.type == .rightMouseUp
else { return nil }
let local = convert(point, from: superview)
guard bounds.contains(local), claims(local) else { return nil }
return self
}
override func rightMouseDown(with event: NSEvent) {
let local = convert(event.locationInWindow, from: nil)
let items = entries(local)
guard !items.isEmpty else { return }
NSMenu.popUpContextMenu(NativeMenuEntry.menu(items, locale: locale), with: event, for: self)
}
}
}