Instantly share code, notes, and snippets.
Last active
August 24, 2025 04:17
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save nighthawk/3612e002f90d86b05024fec7a53230e2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // MiniMenu.swift | |
| // | |
| // Created by Adrian Schönig on 21/8/2025. | |
| // | |
| // Gist: https://gist.github.com/nighthawk/3612e002f90d86b05024fec7a53230e2 | |
| // | |
| import SwiftUI | |
| public protocol MiniMenuAction { | |
| var title: String { get } | |
| var iconSystemName: String? { get } | |
| var attributes: MiniMenu.Attributes { get } | |
| var state: MiniMenu.State { get } | |
| var prominent: Bool { get } | |
| #if os(macOS) | |
| var alternate: Self? { get } | |
| #endif | |
| } | |
| extension MiniMenuAction { | |
| public var iconSystemName: String? { nil } | |
| public var attributes: MiniMenu.Attributes { [] } | |
| public var state: MiniMenu.State { .off } | |
| public var prominent: Bool { false } | |
| #if os(macOS) | |
| public var alternate: Self? { self } | |
| #endif | |
| } | |
| public enum MiniMenu { | |
| public enum State: Int, Sendable { | |
| case off = 0 | |
| case on = 1 | |
| case mixed = 2 | |
| } | |
| public struct Attributes: OptionSet, Sendable { | |
| public static let disabled = MiniMenu.Attributes(rawValue: 1 << 0) | |
| public static let destructive = MiniMenu.Attributes(rawValue: 1 << 1) | |
| public static let hidden = MiniMenu.Attributes(rawValue: 1 << 2) | |
| public static let keepsMenuPresented = MiniMenu.Attributes(rawValue: 1 << 3) | |
| public init(rawValue: UInt) { | |
| self.rawValue = rawValue | |
| } | |
| public let rawValue: UInt | |
| } | |
| typealias Content<A> = [[MiniMenu.Option<A>]] | |
| public enum Option<Action>: Sendable where Action: Sendable { | |
| case action(Action, enabled: Bool = true) | |
| case group(Group<Action>) | |
| } | |
| public struct Group<Action>: Sendable where Action: Sendable { | |
| let suboptions: [[Option<Action>]] | |
| let title: String | |
| var iconSystemName: String? = nil | |
| var isEmpty: Bool { | |
| suboptions.isEmpty || suboptions.first?.isEmpty == true | |
| } | |
| } | |
| } | |
| extension MiniMenu.Group: Equatable where Action: Equatable {} | |
| extension MiniMenu.Group: Hashable where Action: Hashable {} | |
| extension MiniMenu.Option: Equatable where Action: Equatable {} | |
| extension MiniMenu.Option: Hashable where Action: Hashable {} | |
| extension MiniMenu.Content { | |
| func map<A, B>(_ transform: (A) -> B) -> MiniMenu.Content<B> where Element: Collection, Element.Element == MiniMenu.Option<A> { | |
| map { inner in | |
| inner.map { option in | |
| switch option { | |
| case .action(let a, let enabled): | |
| return .action(transform(a), enabled: enabled) | |
| case .group(let group): | |
| return .group(.init( | |
| suboptions: group.suboptions.map(transform), | |
| title: group.title, | |
| iconSystemName: group.iconSystemName | |
| )) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - SwiftUI builder | |
| extension MiniMenu { | |
| @ViewBuilder | |
| private static func actionLabel<A: MiniMenuAction>(for action: A) -> some View { | |
| Label { | |
| Text(action.title) | |
| } icon: { | |
| if let systemName = action.iconSystemName { | |
| Image(systemName: systemName) | |
| } | |
| } | |
| .frame(minWidth: 32, minHeight: 32) | |
| } | |
| @ViewBuilder | |
| static func buildAction<A: MiniMenuAction>(for action: A, enabled: Bool = true, actionHandler: @escaping (A) -> Void) -> some View { | |
| if action.attributes.contains(.destructive) { | |
| Button(role: .destructive) { | |
| actionHandler(action) | |
| } label: { | |
| actionLabel(for: action) | |
| } | |
| .disabled(!enabled || action.attributes.contains(.disabled)) | |
| .frame(minHeight: 44) | |
| } else if action.state == .on { | |
| Toggle(isOn: .init(get: { true }, set: { _ in actionHandler(action) })) { | |
| actionLabel(for: action) | |
| } | |
| .disabled(!enabled || action.attributes.contains(.disabled)) | |
| .frame(minHeight: 44) | |
| } else { | |
| Button { | |
| actionHandler(action) | |
| } label: { | |
| actionLabel(for: action) | |
| } | |
| .disabled(!enabled || action.attributes.contains(.disabled)) | |
| .frame(minHeight: 44) | |
| } | |
| } | |
| @ViewBuilder | |
| static func buildGroup<A>(for group1: Group<A>, actionHandler: @escaping (A) -> Void) -> some View where A: MiniMenuAction, A: Hashable { | |
| // Only allow two level deep as SwiftUI doesn't allow recursion | |
| // here, due to 'which defines the opaque type in terms of | |
| // itself' | |
| SwiftUI.Menu { | |
| ForEach(group1.suboptions, id: \.self) { group2 in | |
| ForEach(group2, id: \.self) { option2 in | |
| Group2(option2: option2, actionHandler: actionHandler) | |
| } | |
| Divider() | |
| } | |
| } label: { | |
| Label { | |
| Text(group1.title) | |
| } icon: { | |
| if let systemName = group1.iconSystemName { | |
| Image(systemName: systemName) | |
| } | |
| } | |
| .frame(minWidth: 32, minHeight: 32) | |
| } | |
| .disabled(group1.isEmpty) | |
| .frame(minHeight: 44) | |
| } | |
| @ViewBuilder | |
| public static func build<A>(for groups: [[Option<A>]], actionHandler: @escaping (A) -> Void) -> some View where A: MiniMenuAction, A: Hashable { | |
| ForEach(groups, id: \.self) { group in | |
| ForEach(group, id: \.self) { option in | |
| switch option { | |
| case .action(let action, let enabled): | |
| buildAction(for: action, enabled: enabled, actionHandler: actionHandler) | |
| case .group(let group1): | |
| // Only allow two level deep as SwiftUI doesn't allow recursion | |
| // here, due to 'which defines the opaque type in terms of | |
| // itself' | |
| SwiftUI.Menu { | |
| ForEach(group1.suboptions, id: \.self) { group2 in | |
| ForEach(group2, id: \.self) { option2 in | |
| Group2(option2: option2, actionHandler: actionHandler) | |
| } | |
| Divider() | |
| } | |
| } label: { | |
| Label { | |
| Text(group1.title) | |
| } icon: { | |
| if let systemName = group1.iconSystemName { | |
| Image(systemName: systemName) | |
| } | |
| } | |
| } | |
| .disabled(group1.isEmpty) | |
| .frame(minHeight: 44) | |
| } | |
| } | |
| Divider() | |
| } | |
| } | |
| } | |
| // Extracted to help the Swift compiler | |
| fileprivate struct Group2<A>: View where A: MiniMenuAction, A: Hashable { | |
| let option2: MiniMenu.Option<A> | |
| let actionHandler: (A) -> Void | |
| var body: some View { | |
| switch option2 { | |
| case .action(let action2, let enabled): | |
| MiniMenu.buildAction(for: action2, enabled: enabled, actionHandler: actionHandler) | |
| case .group(let group3): | |
| SwiftUI.Menu { | |
| ForEach(group3.suboptions, id: \.self) { group4 in | |
| ForEach(group4, id: \.self) { option3 in | |
| switch option3 { | |
| case .action(let action3, let enabled): | |
| MiniMenu.buildAction(for: action3, enabled: enabled, actionHandler: actionHandler) | |
| case .group(let group5): | |
| Text(group5.title) | |
| } | |
| } | |
| Divider() | |
| } | |
| } label: { | |
| Label { | |
| Text(group3.title) | |
| } icon: { | |
| if let systemName = group3.iconSystemName { | |
| Image(systemName: systemName) | |
| } | |
| } | |
| } | |
| .disabled(group3.isEmpty) | |
| } | |
| } | |
| } | |
| // MARK: - AppKit builder | |
| #if canImport(AppKit) | |
| import AppKit | |
| extension MiniMenu { | |
| @MainActor | |
| public static func buildMenu<A: MiniMenuAction>(for actions: [[MiniMenu.Option<A>]], builder: (A) -> NSMenuItem) -> NSMenu { | |
| let menu = NSMenu() | |
| menu.autoenablesItems = false | |
| for actionGroup in actions { | |
| for menuItem in actionGroup { | |
| switch menuItem { | |
| case .action(let action, let enabled): | |
| var attributes = action.attributes | |
| if !enabled { | |
| attributes.insert(.disabled) | |
| } | |
| let item = builder(action) | |
| item.isEnabled = !attributes.contains(.disabled) | |
| menu.addItem(item) | |
| if let alternate = action.alternate { | |
| let alternateItem = builder(alternate) | |
| alternateItem.keyEquivalentModifierMask = [.option] | |
| alternateItem.isEnabled = !attributes.contains(.disabled) | |
| alternateItem.isAlternate = true | |
| menu.addItem(alternateItem) | |
| } | |
| case let .group(group): | |
| let subMenu = buildMenu(for: group.suboptions, builder: builder) | |
| let subMenuItem = NSMenuItem(title: group.title, action: nil, keyEquivalent: "") | |
| menu.addItem(subMenuItem) | |
| menu.setSubmenu(subMenu, for: subMenuItem) | |
| } | |
| } | |
| menu.addItem(.separator()) | |
| } | |
| return menu | |
| } | |
| } | |
| extension MiniMenu.State { | |
| public var appKit: NSControl.StateValue { | |
| switch self { | |
| case .on: .on | |
| case .off: .off | |
| case .mixed: .mixed | |
| } | |
| } | |
| } | |
| #endif | |
| // MARK: - UIKit builder | |
| #if canImport(UIKit) | |
| import UIKit | |
| extension MiniMenu { | |
| static func buildMenuChildren<A>(for groups: [[MiniMenu.Option<A>]], actionHandler: @escaping (A) -> Void) -> [UIMenuElement] where A: MiniMenuAction { | |
| groups.map { group in | |
| let children = group.compactMap { buildMenuItem(for: $0, actionHandler: actionHandler) } | |
| return UIMenu(options: .displayInline, children: children) | |
| } | |
| } | |
| static func buildMenuItem<A: MiniMenuAction>(for option: Option<A>, actionHandler: @escaping (A) -> Void) -> UIMenuElement? { | |
| switch option { | |
| case .action(let action, false) where action.state == .off: | |
| // Hide actions that don't trigger anything without indicating why, | |
| // e.g., "Unhide 0 albums", but keep those that don't trigger anything | |
| // but have a state, e.g., Add to Collection => It's in there already. | |
| return nil | |
| case .action(let action, let enabled): | |
| var attributes = action.attributes | |
| if !enabled { | |
| attributes.insert(.disabled) | |
| } | |
| return UIAction( | |
| title: action.title, | |
| image: action.iconSystemName.flatMap { UIImage(systemName: $0) }, | |
| identifier: nil, | |
| attributes: attributes.uiKit, | |
| state: action.state.uiKit | |
| ) { _ in actionHandler(action) } | |
| case .group(let group): | |
| let suboptions = group.suboptions | |
| return UIMenu( | |
| title: group.title, | |
| image: group.iconSystemName.flatMap { UIImage(systemName: $0) }, | |
| children: buildMenuChildren(for: suboptions, actionHandler: actionHandler) | |
| ) | |
| } | |
| } | |
| @MainActor | |
| static func buildMenu<A>(title: String = "", for groups: [[MiniMenu.Option<A>]], actionHandler: @escaping (A) -> Void) -> UIMenu where A: MiniMenuAction { | |
| return UIMenu(title: title, children: buildMenuChildren(for: groups, actionHandler: actionHandler)) | |
| } | |
| } | |
| extension UIViewController { | |
| public static func buildContextActions<A: MiniMenuAction>(for actions: [[MiniMenu.Option<A>]], includeSuggestedActions: Bool = false, handler: @escaping (A) -> Void) -> UIContextMenuActionProvider { | |
| return { suggestedActions in | |
| let items = MiniMenu.buildMenuChildren(for: actions, actionHandler: handler) | |
| if includeSuggestedActions { | |
| return UIMenu(children: suggestedActions + items) | |
| } else { | |
| return UIMenu(children: items) | |
| } | |
| } | |
| } | |
| } | |
| extension MiniMenu.State { | |
| var uiKit: UIMenuElement.State { | |
| switch self { | |
| case .on: .on | |
| case .off: .off | |
| case .mixed: .mixed | |
| } | |
| } | |
| } | |
| extension MiniMenu.Attributes { | |
| var uiKit: UIMenuElement.Attributes { | |
| var options: UIMenuElement.Attributes = [] | |
| if contains(.destructive) { | |
| options.insert(.destructive) | |
| } | |
| if contains(.disabled) { | |
| options.insert(.disabled) | |
| } | |
| if contains(.hidden) { | |
| options.insert(.hidden) | |
| } | |
| if contains(.keepsMenuPresented) { | |
| options.insert(.keepsMenuPresented) | |
| } | |
| return options | |
| } | |
| } | |
| #endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment