227 lines
8.1 KiB
Swift
227 lines
8.1 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// A destructive action inside a SwiftUI context menu.
|
|
///
|
|
/// Carries `ButtonRole.destructive` and the trash glyph, and nothing else. The
|
|
/// title used to be painted red by hand, on both this and the AppKit side; that
|
|
/// made Delete the loudest thing in a menu it merely happens to end, and the red
|
|
/// was ours rather than the system's — it did not follow appearance, selection
|
|
/// highlight, or a disabled item. The role says what the item is and lets macOS
|
|
/// decide how to say it.
|
|
struct DestructiveMenuButton: View {
|
|
let title: LocalizedStringKey
|
|
let systemImage: String
|
|
let action: () -> Void
|
|
|
|
init(
|
|
_ title: LocalizedStringKey,
|
|
systemImage: String = MenuSymbol.delete,
|
|
action: @escaping () -> Void
|
|
) {
|
|
self.title = title
|
|
self.systemImage = systemImage
|
|
self.action = action
|
|
}
|
|
|
|
var body: some View {
|
|
Button(title, systemImage: systemImage, role: .destructive, action: action)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
var title: String
|
|
/// 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 = {}
|
|
|
|
static let separator = NativeMenuEntry(title: "", isEnabled: false)
|
|
|
|
var isSeparator: Bool { title.isEmpty }
|
|
|
|
static func menu(_ entries: [NativeMenuEntry]) -> NSMenu {
|
|
let menu = NSMenu()
|
|
menu.autoenablesItems = false
|
|
fill(menu, with: entries)
|
|
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]) {
|
|
menu.removeAllItems()
|
|
menu.autoenablesItems = false
|
|
for entry in entries {
|
|
menu.addItem(entry.menuItem())
|
|
}
|
|
}
|
|
|
|
func menuItem() -> NSMenuItem {
|
|
guard !isSeparator else { return .separator() }
|
|
let item = NSMenuItem(title: title, 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)
|
|
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.setPresented = { isPresented = $0 }
|
|
}
|
|
|
|
@MainActor
|
|
final class RowMenuView: NSView, NSMenuDelegate {
|
|
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)
|
|
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
|
|
}
|
|
|
|
@MainActor
|
|
final class BlankAreaMenuView: NSView {
|
|
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), with: event, for: self)
|
|
}
|
|
}
|
|
}
|