Skip to content

Instantly share code, notes, and snippets.

@nighthawk
Last active August 24, 2025 04:17
Show Gist options
  • Select an option

  • Save nighthawk/3612e002f90d86b05024fec7a53230e2 to your computer and use it in GitHub Desktop.

Select an option

Save nighthawk/3612e002f90d86b05024fec7a53230e2 to your computer and use it in GitHub Desktop.
//
// 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