A lightweight Swift package for standardizing UI commands (menu items, toolbar items, buttons) around a simple Intent model.
It separates what the UI shows (name, hint, icon, shortcut) from what it does (closure to invoke, enable/disable, checkmark, and dynamic title), so you can declare commands once and reuse them across UI surfaces.
Repository https://github.com/magesteve/APGIntentKit with current version 0.6.0
- Info (
APGIntentInfo) → UI-facing metadata (name, hint, SF Symbol, shortcut). - Action (
APGIntentAction) → Behavior & appearance (perform closure + appearance closure). - Adapters (macOS) →
NSMenuItem,NSToolbarItem, andNSButtonthat bind to intents.
Status: macOS-focused, designed to expand to other Apple platforms.
- Features
- Installation
- Concepts
- Quick Start
- Registering Info
- Registering Actions
- Using With Menus
- Using With Toolbars
- Using With Buttons
- Window vs App Scope
- Validation & Dynamic Appearance
- Threading & Actors
- Token Conventions
- FAQ
- Sample Code
- License
- Single source of truth for command metadata (name, long/short labels, hint/description, SF Symbol, key equivalents).
- Behavior & appearance logic (enable/disable, checkmark state, dynamic title) defined alongside the action.
- Drop-in macOS UI adapters:
APGIntentMenuItem(validates viaNSMenuItemValidation)APGIntentToolbarItem(validates viaNSToolbarItem.validate())APGIntentMacButton(validates on demand)
- Global (app-level) & window-local action registries, with sensible shadowing rules.
Add to your Package.swift:
.package(url: "https://github.com/your-org/APGIntentKit.git", from: "0.3.0")Then add "APGIntentKit" to your target dependencies.
Minimum: macOS 11+ (SF Symbols in toolbar items require 11+).
The macOS adapters are wrapped in #if os(macOS).
public struct APGIntentInfo: Hashable, Sendable {
public let token: String
public let name: String
public let alwaysOn: Bool
public let shortName: String?
public let longName: String?
public let description: String?
public let hint: String?
public let symbolName: String?
public let menuKey: String?
}tokenis the unique ID and joins UI/adapters to your behavior.alwaysOnskips validation (useful for About/Help, etc.).shortName/longNamefeed toolbars/palette.symbolNameshould be an SF Symbol (e.g."lightbulb").menuKeysets a ⌘ key equivalent onAPGIntentMenuItem.
public typealias APGIntentActionClosure = @Sendable @MainActor (String) -> Void
public typealias APGIntentAppearanceClosure = @Sendable (String) -> (Bool, Bool?, String?)- Both closures receive a
param: String(e.g., document ID, selection key, mode). - Appearance returns:
enabled: BoolisChecked: Bool?(nil = no checkmark)overriddenTitle: String?(nil = keep default label)
APGIntentInfoList.shared.add(contentsOf: [
APGIntentInfo(token: "about",
name: "About My App",
symbolName: "questionmark.circle"),
APGIntentInfo(token: "faq",
name: "FAQ",
symbolName: "book"),
APGIntentInfo(token: "bulb.toggle",
name: "Bulb",
symbolName: "lightbulb",
menuKey: "1",
alwaysOn: false)
])APGIntentActionList.sharedApp.addAction(token: "about") { _ in
// Show About window
}
APGIntentActionList.sharedApp.addAction(token: "faq") { _ in
// Show FAQ window
}// e.g., inside a view controller that owns `fBulbOn` state
guard let helper = findIntentHelper(for: self) else { return }
helper.addWindowAction(
token: "bulb.toggle",
action: { param in
// param might be a context string, e.g. "primary" vs "secondary"
fBulbOn.toggle()
},
appearance: { param in
// Enabled if param matches expected context
let enabled = (param.isEmpty || param == "primary")
// Checked if bulb is on; no overridden title
return (enabled, fBulbOn, nil)
}
)APGIntentMacTools.addAppMenuIntents(about: ["about"], settings: [])
APGIntentMacTools.addMenuBeforeHelp(named: "Tools", tokens: ["bulb.toggle"])If you need to pass a parameter, you can create and add items yourself:
#if os(macOS)
let item = APGIntentMenuItem(token: "bulb.toggle", param: "primary")
NSApp.mainMenu?.item(withTitle: "Tools")?.submenu?.addItem(item)
#endifguard let helper = findIntentHelper(for: self) else { return }
helper.addIntentToolbar(unique: "mywindow",
defaults: ["bulb.toggle"],
extras: ["about", "faq"])#if os(macOS)
let aboutButton = APGIntentMacButton(token: "about")
let bulbButton = APGIntentMacButton(token: "bulb.toggle", param: "primary")
#endifKeep all your visible command metadata in one place:
APGIntentInfoList.shared.add(
APGIntentInfo(token: "file.new",
name: "New Document",
shortName: "New",
description: "Create a new document",
symbolName: "doc.badge.plus",
menuKey: "n")
)Each APGIntentInfo defines what the UI shows for a command.
It does not perform logic — it only carries user-facing metadata (names, tooltips, icons, shortcuts).
You can attach actions globally (app scope) or to a window (window scope).
Window scope shadows app scope for the same token.
App scope:
APGIntentActionList.sharedApp.addAction(token: "file.new") { param in
// Create document, maybe param encodes a template kind
}Window scope:
helper.addWindowAction(token: "edit.copy") { param in
// Copy from this window’s selection
}With appearance:
helper.addWindowAction(
token: "view.zoomIn",
action: { _ in zoomIn() },
appearance: { _ in
let canZoomIn = zoomLevel < 400
return (canZoomIn, nil, nil)
}
)- Add items to the App menu / a custom menu / Help menu via
APGIntentMacTools. - Or instantiate
APGIntentMenuItem(token:param:)yourself for full control.
APGIntentMacTools.addHelpMenuIntents(help: ["faq"])Validation is automatic via NSMenuItemValidation.
If your APGIntentInfo.alwaysOn is true, the item remains enabled without calling appearance.
Attach a toolbar that is automatically populated with your intent items:
helper.addIntentToolbar(unique: "documentWindow",
defaults: ["view.zoomIn", "view.zoomOut"],
extras: ["about", "faq"])- Items are identified as
"apgintent-" + token. APGIntentToolbarItemcalls yourappearance(param)onvalidate()and updates:- enabled/disabled
- checkmark state (via alternate filled SF Symbol name)
- optional overridden label
APGIntentMacButton forwards its token and param to your action/appearance:
let runButton = APGIntentMacButton(token: "build.run")
runButton.param = "release"
// Optional: call `intentValidateUI()` if your UI needs to refresh title/state immediately.- Window-local actions live in the window’s
APGIntentMacWindowHelper. - App-global actions live in
APGIntentActionList.sharedApp. - Lookup order is window first, then app.
This allows document windows to override behavior while keeping app-wide fallbacks.
Helpers:
APGIntentMacWindowHelper.findTopmostActionInfo(token: "file.save")
APGIntentMacWindowHelper.findWindowActionInfo(window: window, token: "file.save")Your appearance(param) decides if a command is available and how it looks:
appearance: { param in
let enabled = selectionCount > 0
let checked = isPinned(selection)
let title = selectionCount == 1 ? "Pin Item" : "Pin \(selectionCount) Items"
return (enabled, checked, title)
}enabled == falsedisables the UI surface.checked == truedraws a checkmark (menus) or uses a “.fill” variant of your symbol (toolbars).titlelets you change the visible label dynamically (toolbars/menus).
APGIntentActionClosureis@MainActorand must update UI on the main thread.APGIntentAppearanceClosureis@Sendable(not@MainActor) and should be fast and side-effect free.
Return a snapshot of the current state; don’t perform async work here.
- Use reverse-DNS-like or dotted tokens for hierarchy, e.g.:
"file.new","file.open","view.zoomIn","tools.bulb.toggle"
- Toolbar item identifiers are
"apgintent-" + token.
Constants you may use:
public let kAPGIntentSymbolDefault = "questionmark.square"
public let kAPGIntentSymbolMark = ".fill" // appended to symbol when “checked”
public let kAPGIntentKeyPrefix = "apgintent-" // toolbar item identifier prefixQ: How do I force UI to re-validate after state changes?
A: Menus revalidate automatically on open. For toolbars/buttons, call standard Cocoa invalidation (e.g., window.toolbar?.validateVisibleItems()), or trigger a UI refresh where appropriate. APGIntentMacButton offers intentValidateUI() to pull fresh titles.
Q: Can I pass structured parameters?
A: The API accepts a String. You can encode small JSON blobs or delimited strings if needed, then parse inside your action/appearance.
Q: What if I need async state for appearance?
A: Keep appearance fast/synchronous. For async checks, cache the result in your model and make appearance read the cache.
Q: What is the difference between window and app scope actions?
A: Window scope actions are tied to a specific document or window, and override app scope actions with the same token. App scope actions apply everywhere unless shadowed.
Q: Does APGIntentKit support SwiftUI or iOS?
A: Currently the adapters are macOS AppKit-based. SwiftUI and UIKit support are on the roadmap.
The APGExample can be found at Repository.
MIT License Created by Steve Sheets, 2025