Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
Swift
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450import AppKitimport CoreImage
/// Custom About window — replaces `NSApp.orderFrontStandardAboutPanel` so/// we can host a real clickable AC chip and a flashing "New Menu Band/// Available!" button when UpdateChecker reports a newer release.////// The standard panel only takes attributed-string credits, so anything/// that needs to be its own NSButton (custom hover, animated tint) has/// to live in a window we own.final class AboutWindowController: NSWindowController, NSWindowDelegate { /// Self-retain so a language-switch-triggered popoverVC rebuild /// (which drops every strong ref the popover held) doesn't tear /// the window out mid-interaction. Cleared in `windowWillClose`. private static var active: AboutWindowController?
private var flashTimer: Timer? private weak var flashButton: NSButton? private var flashOn = false private let updateInfo: UpdateChecker.VersionInfo? private let onOpenPlugins: (() -> Void)? /// Called when the user picks a language in the flat-map picker, with /// the language's own name + short code — the host speaks it through /// the Menu Band fx (easter egg). Nil = no audio, just the switch. private let onSpeakLanguage: ((String, String) -> Void)? /// Called on each icon-card flip to play a drum hit (paired with the /// sparkle chord) so flipping the card builds a little beat. private let onPlayDrum: (() -> Void)? /// Returns the set of currently-sounding MIDI notes, polled on each /// `menuBandLitNotesChanged` to drive the icon's live key glow. private let litNotesProvider: (() -> Set<UInt8>)? private var litNotesObserver: NSObjectProtocol?
/// Live-updating chrome — rebuilt against the current system accent /// whenever `NSColor.systemColorsDidChangeNotification` fires. The icon /// and Plugins chip are the two surfaces that take their tint from /// `controlAccentColor`; everything else in the About window is brand /// art (AC purple, NELA secondary label) and stays put. /// The icon ↔ QR two-card stack at the top of the panel: hover fans /// the cards apart to peek, clicking shuffles the front card behind. private weak var cardStack: CardStackView? private weak var pluginsButton: HoverLinkButton? private var accentObserver: NSObjectProtocol?
/// Strong ref so the viewer window stays alive while it's open — /// AboutWindowController is the only thing that holds it. private var crashViewer: CrashViewerWindowController?
init(updateInfo: UpdateChecker.VersionInfo?, onOpenPlugins: (() -> Void)? = nil, onSpeakLanguage: ((String, String) -> Void)? = nil, onPlayDrum: (() -> Void)? = nil, litNotesProvider: (() -> Set<UInt8>)? = nil) { self.updateInfo = updateInfo self.onOpenPlugins = onOpenPlugins self.onSpeakLanguage = onSpeakLanguage self.onPlayDrum = onPlayDrum self.litNotesProvider = litNotesProvider let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 340), styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false ) window.titlebarAppearsTransparent = true window.titleVisibility = .hidden window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false // Float above other apps so the user notices the update prompt // even if they triggered About from the popover and immediately // tabbed away. .floating sits above normal windows but below // status items, which is exactly what we want. // The Menu Band popover panel runs at .popUpMenu (101); sit one // step above it so the About window paints over the popover // instead of being hidden behind it when both are visible. // Ordinary window level. These secondary windows used to sit at // popUpMenu + 1 so the status-bar popover couldn't bury them — but that // floated them above every other app on the Mac for as long as they // stayed open. Each one activates the app and orders front when shown, // which lifts it over the popover at the only moment that matters. window.level = .normal super.init(window: window) window.delegate = self buildContent() // Live accent-color updates: refresh the tinted icon and the // Plugins chip whenever the user changes the system accent in // System Settings → Appearance. NSImageView with a non-template // image won't auto-redraw, and HoverLinkButton's layer caches // the cgColor it baked at creation, so both need an explicit // poke on every accent change. accentObserver = NotificationCenter.default.addObserver( forName: NSColor.systemColorsDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.refreshAccentChrome() }
// Dev affordances (mirroring the showAbout/showPopover remotes): // drive the icon-card hover/flip from the shell so the animation // can be triggered and screenshotted without a real pointer. let dnc = DistributedNotificationCenter.default() debugObservers.append(dnc.addObserver( forName: .init("computer.aestheticcomputer.menuband.cardHover"), object: nil, queue: .main ) { [weak self] _ in self?.cardHoverPreview.toggle() self?.cardStack?.setHoverPreview(self?.cardHoverPreview ?? false) }) debugObservers.append(dnc.addObserver( forName: .init("computer.aestheticcomputer.menuband.cardFlip"), object: nil, queue: .main ) { [weak self] _ in self?.cardStack?.flip() })
// Live icon keyboard: refresh the lit keys whenever the held-note // set changes while the About window is open. litNotesObserver = NotificationCenter.default.addObserver( forName: .menuBandLitNotesChanged, object: nil, queue: .main ) { [weak self] _ in self?.refreshLitKeys() } }
/// Map the currently-held MIDI notes onto the icon's 5 chromatic keys /// (C C♯ D D♯ E) and light them. Pitch class folds onto the five keys /// so any octave lands consistently and higher notes still animate. private func refreshLitKeys() { guard let provider = litNotesProvider else { return } let indices = Set(provider().map { Int($0) % 12 % 5 }) cardStack?.setLitKeys(indices) }
/// Tracks the toggled state of the `cardHover` debug remote. private var cardHoverPreview = false private var debugObservers: [NSObjectProtocol] = []
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
deinit { flashTimer?.invalidate() if let observer = accentObserver { NotificationCenter.default.removeObserver(observer) } let dnc = DistributedNotificationCenter.default() for observer in debugObservers { dnc.removeObserver(observer) } if let o = litNotesObserver { NotificationCenter.default.removeObserver(o) } }
/// Open or refocus the singleton About window. Dedupes via the /// static `active` registry so callers don't have to track the /// instance — and so a popoverVC rebuild can't accidentally /// orphan a live window. @discardableResult static func show(updateInfo: UpdateChecker.VersionInfo?, onOpenPlugins: (() -> Void)? = nil, onSpeakLanguage: ((String, String) -> Void)? = nil, onPlayDrum: (() -> Void)? = nil, litNotesProvider: (() -> Set<UInt8>)? = nil) -> AboutWindowController { active?.close() let ctrl = AboutWindowController(updateInfo: updateInfo, onOpenPlugins: onOpenPlugins, onSpeakLanguage: onSpeakLanguage, onPlayDrum: onPlayDrum, litNotesProvider: litNotesProvider) ctrl.present() return ctrl }
func present() { guard let window = window else { return } // Park ourselves in the static registry — keeps this controller // alive even if the popoverVC that opened us gets thrown away. AboutWindowController.active = self window.center() // The Menu Band popover panel runs at .popUpMenu (101); sit one // step above it so the About window paints over the popover // instead of being hidden behind it when both are visible. window.level = .normal NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) startFlashingIfNeeded() }
func windowWillClose(_ notification: Notification) { flashTimer?.invalidate() flashTimer = nil if AboutWindowController.active === self { AboutWindowController.active = nil } }
// MARK: - Layout
private func buildContent() { guard let window = window else { return } // REUSE the existing content view on a rebuild (language switch) rather // than replacing window.contentView — replacing it re-composites the // window's liquid-glass panel and causes a visible flash. Clearing and // refilling the same view swaps the strings without touching the chrome. let content: NSView if let existing = window.contentView { existing.subviews.forEach { $0.removeFromSuperview() } content = existing } else { content = NSView() content.translatesAutoresizingMaskIntoConstraints = false window.contentView = content }
let stack = NSStackView() stack.orientation = .vertical stack.alignment = .centerX stack.spacing = 12 stack.edgeInsets = NSEdgeInsets(top: 10, left: 28, bottom: 24, right: 28) stack.translatesAutoresizingMaskIntoConstraints = false content.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), stack.topAnchor.constraint(equalTo: content.topAnchor), stack.bottomAnchor.constraint(equalTo: content.bottomAnchor), ])
// App icon — centered directly above the title, the same // masthead rhythm as the standard macOS "About This Mac" panel. // Prefer the hue-rotated tint that IconTinter generates against // the current accent (matches the Finder icon the user sees // everywhere else); fall back to the un-tinted bundle icon if // the tinter can't reach the source (e.g. swift-run dev builds // without an .app wrapper). if let icon = IconTinter.tintedIcon() ?? Self.loadAppIcon() { // Two physical cards — the app icon in front, a QR code to // menuband.app behind. Hover fans them apart so the QR // peeks out; clicking shuffles the front card behind the // other (and plays a sparkle + particle burst). Modeled on // the stacked card-deck in aesthetic.computer's wg.mjs. let qr = Self.qrCodeImage(for: "https://menuband.app", side: 96) let cards = CardStackView(front: icon, back: qr) cards.translatesAutoresizingMaskIntoConstraints = false cards.toolTip = "menuband.app" cards.onFlip = { [weak self] in EasterEggChord.shared.play() self?.onPlayDrum?() } stack.addArrangedSubview(cards) // Tighten the gap between the icon and the "Menu Band" title. // The card view reserves vertical room for the hover fan-out // (intrinsicContentSize), so the default stack spacing on top of // that reads as too much dead space — pull the title in close. stack.setCustomSpacing(2, after: cards) cardStack = cards }
// "Menu Band" is a coined brand name (à la GarageBand / Logic // Pro), so it stays constant across languages — Apple localizes // descriptive app names but never brand ones. The surrounding // copy (tagline, links) is what gets localized. let nameLabel = NSTextField(labelWithString: "Menu Band") nameLabel.font = NSFont.systemFont(ofSize: 18, weight: .bold) nameLabel.alignment = .center stack.addArrangedSubview(nameLabel) stack.setCustomSpacing(10, after: nameLabel)
// (Version moved to a small gray line above the copyright at the // bottom — see the footer — matching how Terminal / Messages // close their About panels.)
// Tagline — body alone (the bold "Menu Band" lead was a // duplicate of the name label above; trimmed to one mention). // Localized body starts with a leading space + lowercase // word ("makes the built-in…"); capitalize the first letter // so it reads as a standalone sentence. let raw = L("popover.about.body").trimmingCharacters(in: .whitespaces) let bodyText = raw.prefix(1).uppercased() + raw.dropFirst() let body = NSTextField(wrappingLabelWithString: String(bodyText)) body.font = NSFont.systemFont(ofSize: 11) body.alignment = .center body.translatesAutoresizingMaskIntoConstraints = false body.preferredMaxLayoutWidth = 264 // Host the tagline in a fixed-height box and center it vertically. // Translations wrap to 2–3 lines at different lengths; a constant // box keeps the panel from reflowing / resizing as the user flips // through the language map. let bodyBox = NSView() bodyBox.translatesAutoresizingMaskIntoConstraints = false bodyBox.addSubview(body) NSLayoutConstraint.activate([ bodyBox.widthAnchor.constraint(equalToConstant: 264), bodyBox.heightAnchor.constraint(equalToConstant: 48), body.leadingAnchor.constraint(equalTo: bodyBox.leadingAnchor), body.trailingAnchor.constraint(equalTo: bodyBox.trailingAnchor), body.centerYAnchor.constraint(equalTo: bodyBox.centerYAnchor), ]) stack.addArrangedSubview(bodyBox)
// (Aesthetic.Computer badge + the NELA / start-a-club invites // moved to the popover's "Jam" button → JamWindow. About stays a // focused identity/settings panel.)
// [v1 cutoff] Pedals (AU plugin picker) chip removed — Liam Hall // pedals / third-party AU hosting are post-v1. The `onOpenPlugins` // hook + AU picker code stay dormant for the post-release revival.
// Language — a flat map of every supported language, shown all at // once as chiclet cells (flag + native label) like the instrument // picker. Click switches the language AND speaks its own name // through the Menu Band fx (easter egg). Replaces the chip+dropdown. stack.setCustomSpacing(16, after: stack.arrangedSubviews.last ?? stack) let langMap = LanguageMapView() langMap.items = Localization.supported.map { LanguageMapView.Item(code: $0.code, label: $0.label, flag: $0.flag) } langMap.selectedCode = Localization.current langMap.translatesAutoresizingMaskIntoConstraints = false // Press/slide speaks the name instantly (sample-board); release commits // the actual language switch. langMap.onPlay = { [weak self] item in self?.onSpeakLanguage?(item.label, item.code) } langMap.onPick = { [weak self] item in self?.switchLanguage(item) } langMap.widthAnchor.constraint(equalToConstant: 264).isActive = true // Height comes from the view's intrinsic size (rows × rowH), so the // grid grows with the number of languages. stack.addArrangedSubview(langMap) let langRow: NSView = langMap
// "Looking For Players?" — opens the Jam window (Aesthetic.Computer // badge + computer-club invites). Lives right under the language // chip so the come-hang-out surface is one tap from identity, and // replaces the old purple "Jam" button that used to sit in the // popover footer. stack.setCustomSpacing(22, after: langRow) let playersLink = HoverFeedbackButton(title: "", target: self, action: #selector(openLookingForPlayers(_:))) let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) playersLink.attributedTitle = NSAttributedString( string: L("popover.about.lookingForPlayers"), attributes: [ .font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: acPurple, .underlineStyle: NSUnderlineStyle.single.rawValue, ]) playersLink.bezelStyle = .regularSquare playersLink.isBordered = false playersLink.translatesAutoresizingMaskIntoConstraints = false playersLink.toolTip = "Aesthetic.Computer · computer clubs" playersLink.titleBrightensOnHover(hoverColor: .white) stack.addArrangedSubview(playersLink)
// (Tips moved to the popover footer; Squawk enable moved to its own // window off the fullscreen keymap view — keeps About uncluttered.)
// [v1 cutoff] "Cassette deck (beta)" + "Percussion split" beta // checkboxes removed — both features are post-v1. Their flags // default off and their live shortcuts stay in place, so the // toggles can return to About after release.
// [v1.5.0 cutoff] "Use AC OS MIDI" checkbox deprecated for this // release — the gm_synth routing (CGMSynth) is still wired up and // the `useACMIDIDefaultsKey` flag stays off by default, so the grid // sounds via the system soundfont. The toggle can return to About // once the AC OS voices are release-ready. (`toggleUseACMIDI(_:)` // remains for when it does.) // // stack.setCustomSpacing(14, after: playersLink) // let acMIDIRow = NSStackView() // acMIDIRow.orientation = .vertical // acMIDIRow.alignment = .leading // acMIDIRow.spacing = 1 // acMIDIRow.translatesAutoresizingMaskIntoConstraints = false // let acMIDIToggle = NSButton( // checkboxWithTitle: "Use AC OS MIDI", // target: self, // action: #selector(toggleUseACMIDI(_:))) // acMIDIToggle.state = UserDefaults.standard // .bool(forKey: MenuBandSynth.useACMIDIDefaultsKey) ? .on : .off // acMIDIToggle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) // acMIDIToggle.translatesAutoresizingMaskIntoConstraints = false // let acMIDISub = NSTextField(labelWithString: // "synthesize GM instruments with the Aesthetic Computer engine " // + "instead of the system soundfont") // acMIDISub.font = NSFont.systemFont(ofSize: 9, weight: .regular) // acMIDISub.textColor = .secondaryLabelColor // acMIDISub.lineBreakMode = .byWordWrapping // acMIDISub.maximumNumberOfLines = 2 // acMIDISub.translatesAutoresizingMaskIntoConstraints = false // acMIDIRow.addArrangedSubview(acMIDIToggle) // acMIDIRow.addArrangedSubview(acMIDISub) // stack.addArrangedSubview(acMIDIRow) // acMIDIRow.widthAnchor.constraint( // equalTo: stack.widthAnchor, constant: -56).isActive = true
// "⚙️ Settings" — pinned to the bottom-RIGHT corner, the peer of 💡 Tips // at bottom-left. It replaces the old "Crashes ⚠️" link, which only // rendered `if !logs.isEmpty` — so a permanent corner slot sat empty // for every user who had never crashed. The corners now hold the two // things that are ALWAYS there; the crash notice, being conditional, // moved into the centered footer where a row can come and go without // leaving a hole. (Corner-pinned to `content`, outside the stack's // flow, so neither link can affect the window's fixed 320×340 height.) let settingsCorner = HoverFeedbackButton(title: "", target: self, action: #selector(openSettings(_:))) let settingsFont = NSFont.systemFont(ofSize: 11, weight: .semibold) let settingsTitle = NSMutableAttributedString( string: "⚙️ ", attributes: [.font: settingsFont]) settingsTitle.append(NSAttributedString( string: "Settings", attributes: [ .font: settingsFont, .foregroundColor: acPurple, .underlineStyle: NSUnderlineStyle.single.rawValue, ])) settingsCorner.attributedTitle = settingsTitle settingsCorner.isBordered = false settingsCorner.bezelStyle = .regularSquare settingsCorner.setButtonType(.momentaryChange) settingsCorner.toolTip = "Open Menu Band Settings" settingsCorner.titleBrightensOnHover(hoverColor: .white) settingsCorner.translatesAutoresizingMaskIntoConstraints = false content.addSubview(settingsCorner) NSLayoutConstraint.activate([ settingsCorner.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -10), settingsCorner.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -14), ])
// "Tips" — a quiet link pinned to the bottom-LEFT corner (mirroring // the crash ⚠️ at bottom-right), opening the Menu Band manual. Corner- // pinned so it doesn't take a row and keeps the brand chrome clean. let tipsCorner = HoverFeedbackButton(title: "", target: self, action: #selector(openTips(_:))) // "💡 Tips" — leading lightbulb (un-underlined), then the purple // underlined word, matching the "Looking for players" link style. let tipsFont = NSFont.systemFont(ofSize: 11, weight: .semibold) let tipsTitle = NSMutableAttributedString( string: "💡 ", attributes: [.font: tipsFont]) tipsTitle.append(NSAttributedString( string: "Tips", attributes: [ .font: tipsFont, .foregroundColor: acPurple, .underlineStyle: NSUnderlineStyle.single.rawValue, ])) tipsCorner.attributedTitle = tipsTitle tipsCorner.isBordered = false tipsCorner.bezelStyle = .regularSquare tipsCorner.setButtonType(.momentaryChange) tipsCorner.toolTip = "Open Menu Band Tips" tipsCorner.titleBrightensOnHover(hoverColor: .white) tipsCorner.translatesAutoresizingMaskIntoConstraints = false content.addSubview(tipsCorner) NSLayoutConstraint.activate([ tipsCorner.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), tipsCorner.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -14), ])
if let info = updateInfo, UpdateChecker.isNewer(info.version, than: UpdateChecker.currentVersion()) { stack.setCustomSpacing(16, after: playersLink) let btn = NSButton(title: "Menu Band \(info.version) Now Available!", target: self, action: #selector(openUpdateLink)) btn.bezelStyle = .rounded btn.controlSize = .large btn.font = NSFont.systemFont(ofSize: 13, weight: .bold) btn.translatesAutoresizingMaskIntoConstraints = false stack.addArrangedSubview(btn) flashButton = btn }
// (The QR code lives as a click-to-reveal easter egg on the app // icon above — see toggleIconQR — rather than a standing footer // badge, keeping the panel Apple-clean.)
// Version line — small gray, sitting just above the copyright, // the way Terminal / Messages close their About panels. let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?" let build = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "?" let versionWord = L("popover.about.version") let versionString = build == version ? "\(versionWord) \(version)" : "\(versionWord) \(version) (\(build))" let versionLabel = NSTextField(labelWithString: versionString) versionLabel.font = NSFont.systemFont(ofSize: 11) versionLabel.textColor = .secondaryLabelColor versionLabel.alignment = .center stack.setCustomSpacing(16, after: stack.arrangedSubviews.last ?? stack) stack.addArrangedSubview(versionLabel) stack.setCustomSpacing(4, after: versionLabel)
// Crash notice — the slot the "Open at Login" checkbox used to occupy // (that checkbox now lives in Settings, where a control belongs; this // band is meant to be inert text). Centered between the version and the // copyright, and rendered ONLY when there are crash logs, so a healthy // install closes on version → copyright exactly as before. Clicking it // opens the same scroll viewer the old corner ⚠️ did. let logs = CrashLogReader.recentLogs() if !logs.isEmpty { let summary = logs.count == 1 ? L("popover.about.crash.summaryOne") : L("popover.about.crash.summaryMany", String(logs.count)) let crashFont = NSFont.systemFont(ofSize: 11, weight: .semibold) let crashTitle = NSMutableAttributedString( string: summary, attributes: [ .font: crashFont, .foregroundColor: acPurple, .underlineStyle: NSUnderlineStyle.single.rawValue, ]) crashTitle.append(NSAttributedString( string: " ⚠️", attributes: [.font: crashFont])) let warn = HoverFeedbackButton(title: "", target: self, action: #selector(viewCrashLogs(_:))) warn.attributedTitle = crashTitle warn.isBordered = false warn.bezelStyle = .regularSquare warn.setButtonType(.momentaryChange) warn.toolTip = "Review the crash reports and send them to Aesthetic Computer." stack.setCustomSpacing(10, after: versionLabel) stack.addArrangedSubview(warn) stack.setCustomSpacing(4, after: warn) }
// Faint copyright line — the quiet footer every macOS About // panel closes with (Terminal: "© 1991–2025 Apple Inc.", // Finder: "™ & © 1983–2025 …"). Tertiary gray so it recedes // beneath the brand chrome. let copyright = NSTextField(labelWithString: "© 2026 Aesthetic, Inc.") copyright.font = NSFont.systemFont(ofSize: 11) copyright.textColor = .tertiaryLabelColor copyright.alignment = .center stack.addArrangedSubview(copyright)
// Breathing room beneath the copyright so the panel doesn't feel // bottom-crowded and the corner links (💡 Tips / crashes, pinned to // the window's bottom edge) get their own quiet band below the text. let bottomSpacer = NSView() bottomSpacer.translatesAutoresizingMaskIntoConstraints = false bottomSpacer.heightAnchor.constraint(equalToConstant: 24).isActive = true stack.setCustomSpacing(8, after: copyright) stack.addArrangedSubview(bottomSpacer) }
/// Open the Jam window ("Looking For Players?") — the social /// come-hang-out surface (Aesthetic.Computer + computer-club invites). @objc private func openLookingForPlayers(_ sender: Any?) { JamWindowController.show() }
/// Pick a language from the flat map: speak its own name through the /// Menu Band fx (easter egg — works even when re-picking the active /// language), then switch + rebuild the panel so every string re-reads. /// Commit a language switch (on release). The spoken name already fired on /// press via `onPlay`, so this does NOT speak — it only applies the strings. private func switchLanguage(_ item: LanguageMapView.Item) { // No-op if it's already the current language — keeps rapid playing snappy. guard item.code != Localization.current else { return } Localization.current = item.code // Defer the (heavier) content rebuild to the next runloop tick so it // never blocks the press / the audio. The string swap applies a beat // after the sound, keeping the tap feeling instant. DispatchQueue.main.async { [weak self] in // buildContent() now reuses + refills the existing content view // (no window.contentView swap), so the language change swaps strings // without the liquid-glass panel flashing. self?.buildContent() } }
// MARK: - Icon loader
/// Look for the app icon. Tries explicit AppIcon.icns paths /// FIRST (the .app bundle's Resources/, and the SwiftPM dev /// repo location) so we don't get stuck with the generic /// SwiftPM folder icon that `NSImage.applicationIconName` /// returns for `swift run` debug binaries. private static func loadAppIcon() -> NSImage? { let exec = Bundle.main.executablePath ?? "" let url = URL(fileURLWithPath: exec) let candidates = [ // Bundled .app: .app/Contents/MacOS/MenuBand → // ../Resources/AppIcon.icns url.deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("Resources/AppIcon.icns"), // Dev (`swift run`): .build-debug/<arch>/debug/MenuBand // → ../../../../AppIcon.icns under slab/menuband/. url.deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("AppIcon.icns"), // Same-dir fallback for any unusual layout. url.deletingLastPathComponent() .appendingPathComponent("AppIcon.icns"), ] for candidate in candidates { if FileManager.default.fileExists(atPath: candidate.path), let img = NSImage(contentsOf: candidate) { return img } } // Last resort: the system's idea of our app icon (works for // properly bundled installs, returns a folder for raw // SwiftPM debug binaries — hence trying the explicit path // candidates first). return NSImage(named: NSImage.applicationIconName) }
// MARK: - Actions
@objc private func openUpdateLink() { if let url = URL(string: "https://menuband.app") { NSWorkspace.shared.open(url) } }
@objc private func openMenubandLanding() { if let url = URL(string: "https://menuband.app") { NSWorkspace.shared.open(url) } }
/// Generate a high-contrast QR code at the requested side /// length (in points). Uses CIQRCodeGenerator with the high /// error-correction level (`H`) so the code stays scannable /// even when projected at small sizes or photographed at an /// angle. The output is upscaled with nearest-neighbour so the /// modules render as crisp squares at any DPI — without that, /// CIImage's default interpolation softens the edges and trips /// some phone scanners. fileprivate static func qrCodeImage(for string: String, side: CGFloat) -> NSImage? { guard let data = string.data(using: .utf8) else { return nil } guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } filter.setValue(data, forKey: "inputMessage") filter.setValue("H", forKey: "inputCorrectionLevel") guard let output = filter.outputImage else { return nil } let scale = side / output.extent.width let scaled = output.transformed( by: CGAffineTransform(scaleX: scale, y: scale)) let context = CIContext() guard let cg = context.createCGImage( scaled, from: scaled.extent) else { return nil } let image = NSImage(cgImage: cg, size: NSSize(width: side, height: side)) image.isTemplate = false return image }
@objc private func openPlugins() { onOpenPlugins?() }
/// Persist the cassette-deck beta flag and tell AppDelegate to /// re-render the menubar icon. Status-item width changes on the /// flip, so the notification handler also resizes the slot. @objc private func toggleTapeFeature(_ sender: NSButton) { let on = sender.state == .on UserDefaults.standard.set( on, forKey: KeyboardIconRenderer.tapeFeatureDefaultsKey) NotificationCenter.default.post( name: .menuBandTapeFeatureChanged, object: nil) }
/// Flip the right-hand percussion split. Writes the shared flag and /// posts so the controller applies side effects (silence drums on /// disable, redraw the icon) on the main runtime. @objc private func togglePercussionSplit(_ sender: NSButton) { // Whole-board master: set BOTH sided keys together, then let the // controller apply side effects on the main runtime. let on = sender.state == .on UserDefaults.standard.set( on, forKey: KeyboardIconRenderer.percussionLeftDefaultsKey) UserDefaults.standard.set( on, forKey: KeyboardIconRenderer.percussionRightDefaultsKey) NotificationCenter.default.post( name: .menuBandPercussionSplitChanged, object: nil) }
/// Persist the "Use AC OS MIDI" flag and notify the runtime so the /// synth flips melodic routing between the native gm_synth voices and /// the system soundfont. AppDelegate forwards the new value to the synth. @objc private func toggleUseACMIDI(_ sender: NSButton) { let on = sender.state == .on UserDefaults.standard.set(on, forKey: MenuBandSynth.useACMIDIDefaultsKey) NotificationCenter.default.post( name: .menuBandUseACMIDIChanged, object: nil) }
/// Open the Menu Band manual ("Tips"). Centralized in AppDelegate so the /// help-book registration (needed for a menu-bar app) lives in one place. @objc private func openTips(_ sender: Any?) { AppDelegate.openTips() }
/// Open Settings — the app's persistent preferences (Open at Login, /// Haptics). "Open at Login" used to be a checkbox stranded in this /// window's gray footer; it lives in Settings now, and this corner link /// is how you get there. @objc private func openSettings(_ sender: Any?) { AppDelegate.openSettings() }
/// Open a scroll panel containing the .ips text for every pending /// diagnostic report. Built fresh each time so it always reflects /// the current contents of `~/Library/Logs/DiagnosticReports/`. @objc private func viewCrashLogs(_ sender: NSButton) { let logs = CrashLogReader.recentLogs() guard !logs.isEmpty else { return } crashViewer?.close() let ctrl = CrashViewerWindowController(logs: logs) crashViewer = ctrl ctrl.present() }
// MARK: - Accent-color refresh
/// Build the Pedals chip's title against the given accent. Pulled /// out so `init` and `refreshAccentChrome` use the same recipe. private func pluginsLabel(for accent: NSColor) -> NSAttributedString { NSAttributedString( string: "Pedals", attributes: [ .font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: accent, ] ) }
/// Re-tint everything that was painted against the previous accent. /// HoverLinkButton keeps its idle/hover backgrounds as NSColor refs /// but bakes a CGColor into the layer on construction; we re-bake /// after refreshing the NSColor sources. private func refreshAccentChrome() { if let tinted = IconTinter.tintedIcon() ?? Self.loadAppIcon() { cardStack?.setIconImage(tinted) } if let btn = pluginsButton { let accent = NSColor.controlAccentColor let bg = accent.withAlphaComponent(0.14) let bd = accent.withAlphaComponent(0.55) let hoverBg = accent.withAlphaComponent(0.32) let hoverBd = accent.withAlphaComponent(0.80) btn.idleBackground = bg btn.idleBorder = bd btn.hoverBackground = hoverBg btn.hoverBorder = hoverBd btn.attributedTitle = pluginsLabel(for: accent) btn.layer?.backgroundColor = bg.cgColor btn.layer?.borderColor = bd.cgColor } }
// MARK: - Flashing
private func startFlashingIfNeeded() { guard flashButton != nil, flashTimer == nil else { return } // 0.45s on / 0.45s off — fast enough to grab attention without // crossing into seizure territory. Two-color alternation between // pink and the system accent so it reads as alive against either // light or dark window chrome. flashTimer = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { [weak self] _ in self?.tickFlash() } tickFlash() }
private func tickFlash() { guard let btn = flashButton else { return } flashOn.toggle() let pink = NSColor(red: 255/255, green: 107/255, blue: 157/255, alpha: 1) let purple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) let version = updateInfo?.version ?? "" let title = version.isEmpty ? "Menu Band Update Available!" : "Menu Band \(version) Now Available!" let attr = NSMutableAttributedString( string: title, attributes: [ .foregroundColor: flashOn ? pink : purple, .font: NSFont.systemFont(ofSize: 13, weight: .bold), ] ) let para = NSMutableParagraphStyle() para.alignment = .center attr.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: attr.length)) btn.attributedTitle = attr }}
/// Two stacked "cards" — the app icon in front, a QR code behind —/// modeled on the card-deck in aesthetic.computer's wg.mjs (absolutely/// stacked layers, offset drop shadows for depth). Hovering fans the/// back card out to peek; clicking shuffles the front card behind the/// other. The controller wires `onFlip` to play a sparkle + particles.final class CardStackView: NSView { /// Fired on each click (the shuffle), for sound + particle juice. var onFlip: (() -> Void)?
private let iconCard = CALayer() private let qrCard = CALayer() /// Which chromatic keys (0…4 = C C♯ D D♯ E) are currently lit. The /// icon face is drawn dynamically (see `iconImage`), so lighting a key /// just regenerates the face — the keyboard is part of the icon, not an /// overlay on a static graphic. private var litKeyIndices: Set<Int> = [] /// Distinct vivid tint per key so the lit keyboard reads as a small /// rainbow, matching the colorful menubar piano aesthetic. private static let keyColors: [NSColor] = [ NSColor(srgbRed: 1.00, green: 0.30, blue: 0.42, alpha: 1), // C red-pink NSColor(srgbRed: 1.00, green: 0.60, blue: 0.18, alpha: 1), // C♯ orange NSColor(srgbRed: 1.00, green: 0.84, blue: 0.22, alpha: 1), // D yellow NSColor(srgbRed: 0.20, green: 0.82, blue: 0.70, alpha: 1), // D♯ teal NSColor(srgbRed: 0.38, green: 0.62, blue: 1.00, alpha: 1), // E blue ] /// Which card is currently in front. Starts on the icon. private var frontIsIcon = true private var hovering = false private var hoverTracking: NSTrackingArea? /// Container holding both cards. Spins as a unit on each click; /// keeping the spin on the deck (not the cards) means it composes /// cleanly with the cards' own slot-swap springs, and additive /// impulses stack so rapid clicks build a continuous spin. private let deck = CALayer()
/// The card's full bounding box (both cards share it). The app-icon /// art fills this with its own transparent margin (the macOS icon /// grid); the QR tile is inset by that SAME margin so the two cards' /// VISIBLE sizes line up instead of the QR overflowing the icon. private let cardSide: CGFloat = 108 /// Transparent margin baked into a macOS app icon — the icon grid /// insets the rounded square by (1024−824)/2/1024 ≈ 0.098 of the /// canvas on each side. The QR tile matches it. private let artMarginRatio: CGFloat = 0.098
init(front: NSImage?, back: NSImage?) { super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = false configureIcon(image: front) configureQR(image: back) // Add QR first so it sits behind; z-positions are managed in // `relayout` and take over once laid out. Both live in the // spinning `deck` container. deck.addSublayer(qrCard) deck.addSublayer(iconCard) layer?.addSublayer(deck) }
/// Continuous-corner radius as a fraction of the side — the macOS /// app-icon "squircle" ratio, so the QR card's corners match the /// app icon's rounding. private static let cornerRatio: CGFloat = 0.2235
required init?(coder: NSCoder) { fatalError() }
/// Reserve room for the fanned-out back card so the deck stays /// centered in the panel and the peek isn't clipped by siblings. override var intrinsicContentSize: NSSize { NSSize(width: 172, height: 122) }
override var isFlipped: Bool { false }
// Let a click work even when the About window isn't key. override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) }
override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = hoverTracking { removeTrackingArea(existing) } let area = NSTrackingArea( rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp], owner: self, userInfo: nil) addTrackingArea(area) hoverTracking = area }
override func layout() { super.layout() let scale = window?.backingScaleFactor ?? 2 iconCard.contentsScale = scale qrCard.contentsScale = scale // The deck fills the view and pivots about its center so a spin // rotates the whole pair around the middle. CATransaction.begin(); CATransaction.setDisableActions(true) deck.anchorPoint = CGPoint(x: 0.5, y: 0.5) deck.bounds = CGRect(origin: .zero, size: bounds.size) deck.position = CGPoint(x: bounds.midX, y: bounds.midY) CATransaction.commit() relayout(animated: false) }
override func mouseEntered(with event: NSEvent) { setHoverPreview(true) } override func mouseExited(with event: NSEvent) { setHoverPreview(false) } override func mouseDown(with event: NSEvent) { flip() }
// MARK: - Public (also driven by debug notifications for validation)
/// Fan the cards out (or back) as if hovered. func setHoverPreview(_ on: Bool) { guard hovering != on else { return } hovering = on // Don't fight an in-flight shuffle; it settles into the right // hover/rest layout when the queue drains. if !shuffling { relayout(animated: true, springDamping: 15) } }
// MARK: - Shuffle
/// Queued count of pending tucks and whether one is animating. /// Tucks run strictly one-at-a-time so the two cards never overlap /// mid-swap — that's what keeps the z-order clean (no flicker) and /// lets rapid clicks read as a continuous shuffle (the queue drains /// back-to-back). private var shuffleQueue = 0 private var shuffling = false private var shuffleTick = 0
/// Enqueue one "tuck the top card behind the other" shuffle. Fires /// the juice callback (throttled chord + particles) per click. func flip() { shuffleQueue += 1 emitNotesBurst() onFlip?() if !shuffling { startNextTuck() } }
/// A burst of little music notes puffing out from BETWEEN the two /// cards (the emitter sits at a z between the back and front card), /// so the front card partly occludes them — they read as coming /// from the seam, not floating on top. private func emitNotesBurst() { guard bounds.width > 0 else { return } let emitter = CAEmitterLayer() emitter.emitterPosition = CGPoint(x: deck.bounds.midX, y: deck.bounds.midY) // Emit from a single point at the seam as an upward fountain. emitter.emitterShape = .point emitter.emitterMode = .points // Normal alpha blending (not additive) so the notes keep their // own color + hard dark shadow and stay legible instead of // blowing out to white where they overlap. emitter.renderMode = .unordered // Between the back card (z 5) and the front card (z 10). emitter.zPosition = 7
let colors: [NSColor] = [ NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1), // purple NSColor(red: 255/255, green: 107/255, blue: 157/255, alpha: 1), // pink NSColor(red: 158/255, green: 212/255, blue: 80/255, alpha: 1), // chartreuse ] emitter.emitterCells = colors.map { color in let cell = CAEmitterCell() cell.contents = Self.noteImage(color: color)? .cgImage(forProposedRect: nil, context: nil, hints: nil) // Just a few notes: low birth rate over a very short burst // (≈3 cells × rate × window → a handful total). cell.birthRate = 32 // Fall longer: live long and arc down under gravity. cell.lifetime = 2.4 cell.lifetimeRange = 0.5 // Pop UP: strong initial upward velocity in a tight cone. cell.velocity = 130 cell.velocityRange = 45 cell.emissionLongitude = .pi / 2 // straight up (+y) cell.emissionRange = .pi / 4 // ~45° upward cone cell.yAcceleration = -200 // gravity → arc back down cell.scale = 0.7 cell.scaleRange = 0.2 cell.scaleSpeed = -0.05 // barely shrink → readable // Fade less: start opaque, decay slowly over the long fall. cell.alphaSpeed = -0.3 // Less rotation so the glyphs stay legible. cell.spin = 0.4 cell.spinRange = 0.8 return cell } deck.addSublayer(emitter) // Very short emission window → only a handful of notes total. DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { emitter.birthRate = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 3.2) { emitter.removeFromSuperlayer() } }
/// A small music-note glyph rendered to an image in `color`, with a /// hard dark drop shadow (blur 0) for legibility against the panel. private static func noteImage(color: NSColor) -> NSImage? { let side: CGFloat = 24 let image = NSImage(size: NSSize(width: side, height: side)) image.lockFocus() let glyph = ["♪", "♫", "♩", "♬"].randomElement() ?? "♪" let shadow = NSShadow() shadow.shadowColor = NSColor.black.withAlphaComponent(0.85) shadow.shadowBlurRadius = 0 // sharp, not soft shadow.shadowOffset = NSSize(width: 1.5, height: -1.5) shadow.set() let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 16, weight: .bold), .foregroundColor: color, ] let s = NSAttributedString(string: glyph, attributes: attrs) let sz = s.size() s.draw(at: NSPoint(x: (side - sz.width) / 2 - 0.75, y: (side - sz.height) / 2 + 0.75)) image.unlockFocus() return image }
private func startNextTuck() { guard shuffleQueue > 0, bounds.width > 0 else { shuffling = false // Settle into the resting (or hovered) stack layout. relayout(animated: true, springDamping: 16) return } shuffling = true shuffleQueue -= 1 shuffleTick += 1
let base = CGPoint(x: deck.bounds.midX, y: deck.bounds.midY) let moving = frontIsIcon ? iconCard : qrCard // current front → behind let staying = frontIsIcon ? qrCard : iconCard // current back → front
// Exit vector: mostly downward (the window has vertical room // there, so the card clears completely without being clipped by // the window edge) with a slightly different angle each time. // Distance > cardSide ⇒ the moving card ends up ENTIRELY outside // the other card's box before the z-swap, so the swap never // happens mid-overlap — no z-fighting, the illusion holds. let jitter = CGFloat.random(in: -0.7...0.7) let angle = -CGFloat.pi / 2 + jitter // down ± ~40° let dist = cardSide * 1.25 let ux = cos(angle), uy = sin(angle) let out = CGPoint(x: base.x + ux * dist, y: base.y + uy * dist) let nudge = CGPoint(x: base.x - ux * cardSide * 0.14, y: base.y - uy * cardSide * 0.14) let half: CFTimeInterval = 0.15
// Phase 1 — moving slides OUT over the top (z above everything); // staying rises to the front slot and nudges out of the way. moving.zPosition = 100 staying.zPosition = 50 move(moving, to: out, scale: 0.96, dur: half, timing: .easeIn) move(staying, to: nudge, scale: 1.0, dur: half, timing: .easeOut)
CATransaction.begin() CATransaction.setCompletionBlock { [weak self] in guard let self = self else { return } // Fully clear now — drop it behind with zero overlap. moving.zPosition = 10 // Phase 2 — moving returns to the (behind) slot; staying // centers as the new front. self.move(moving, to: base, scale: 0.9, dur: half, timing: .easeOut) self.move(staying, to: base, scale: 1.0, dur: half, timing: .easeInEaseOut) CATransaction.begin() CATransaction.setCompletionBlock { [weak self] in guard let self = self else { return } self.frontIsIcon.toggle() // swap committed self.startNextTuck() // keep shuffling if queued } CATransaction.commit() } CATransaction.commit() }
/// Animate a card's position + uniform scale together, reading the /// from-value off the live presentation so re-entrant moves chain. private func move(_ card: CALayer, to center: CGPoint, scale: CGFloat, dur: CFTimeInterval, timing: CAMediaTimingFunctionName) { let transform = CATransform3DMakeScale(scale, scale, 1) let pos = CABasicAnimation(keyPath: "position") pos.fromValue = card.presentation()?.position ?? card.position pos.toValue = center let tform = CABasicAnimation(keyPath: "transform") tform.fromValue = card.presentation()?.transform ?? card.transform tform.toValue = transform let group = CAAnimationGroup() group.animations = [pos, tform] group.duration = dur group.timingFunction = CAMediaTimingFunction(name: timing) card.add(group, forKey: "move") card.position = center card.transform = transform }
/// Kept for API compatibility with the accent-refresh path. The icon /// face is now drawn dynamically (fixed purple identity), so an /// accent re-tint just redraws the current state rather than swapping /// in a hue-rotated PNG. func setIconImage(_ image: NSImage) { redrawIcon() }
// MARK: - Geometry
/// The app-icon card. The face is drawn dynamically by `iconImage` — /// the purple squircle + the piano keyboard are one live drawing, so /// notes light real keys instead of glowing over a static PNG. private func configureIcon(image: NSImage?) { iconCard.bounds = CGRect(x: 0, y: 0, width: cardSide, height: cardSide) iconCard.anchorPoint = CGPoint(x: 0.5, y: 0.5) iconCard.contentsGravity = .resizeAspect iconCard.contentsScale = 2 applyCardShadow(iconCard, path: nil) redrawIcon() }
/// Light the icon's piano keys for the given chromatic key indices /// (0…4 = C C♯ D D♯ E). Driven live from held notes by the About /// window; empty clears them. Cheap — redraws one small face image. func setLitKeys(_ indices: Set<Int>) { guard indices != litKeyIndices else { return } litKeyIndices = indices redrawIcon() }
private func redrawIcon() { CATransaction.begin(); CATransaction.setDisableActions(true) iconCard.contents = Self.iconImage(litKeys: litKeyIndices) .cgImage(forProposedRect: nil, context: nil, hints: nil) CATransaction.commit() }
/// Draw the Menu Band app icon from scratch: a purple gradient /// squircle with a cream piano keyboard (3 white + 2 black keys). /// `litKeys` paints those chromatic keys in their vivid tints so the /// icon becomes a live keyboard. Top-left origin (`flipped: true`). static func iconImage(litKeys: Set<Int>, px: CGFloat = 256) -> NSImage { // Palette sampled from the original AppIcon art. let tl = NSColor(srgbRed: 211/255, green: 80/255, blue: 196/255, alpha: 1) let br = NSColor(srgbRed: 67/255, green: 29/255, blue: 113/255, alpha: 1) let cream = NSColor(srgbRed: 246/255, green: 242/255, blue: 232/255, alpha: 1) let black = NSColor(srgbRed: 27/255, green: 26/255, blue: 25/255, alpha: 1) let edge = NSColor(srgbRed: 40/255, green: 28/255, blue: 22/255, alpha: 1) func R(_ x0: CGFloat, _ y0: CGFloat, _ x1: CGFloat, _ y1: CGFloat) -> CGRect { CGRect(x: x0 * px, y: y0 * px, width: (x1 - x0) * px, height: (y1 - y0) * px) } return NSImage(size: NSSize(width: px, height: px), flipped: true) { _ in // Squircle body (macOS icon margin baked in so it matches the // QR card's visible size). let margin = px * 0.098 let sq = CGRect(x: margin, y: margin, width: px - 2 * margin, height: px - 2 * margin) let radius = sq.width * 0.2235 let body = NSBezierPath(roundedRect: sq, xRadius: radius, yRadius: radius) if let ctx = NSGraphicsContext.current?.cgContext { ctx.saveGState() body.addClip() let cs = CGColorSpaceCreateDeviceRGB() if let g = CGGradient(colorsSpace: cs, colors: [tl.cgColor, br.cgColor] as CFArray, locations: [0, 1]) { // Flipped context: (minX,minY) is top-left, (maxX,maxY) // bottom-right → magenta TL to deep-purple BR. ctx.drawLinearGradient( g, start: CGPoint(x: sq.minX, y: sq.minY), end: CGPoint(x: sq.maxX, y: sq.maxY), options: []) } ctx.restoreGState() } else { tl.setFill(); body.fill() }
// Cream keyboard plate. let plate = R(0.254, 0.428, 0.744, 0.570) let platePath = NSBezierPath(roundedRect: plate, xRadius: px * 0.012, yRadius: px * 0.012) cream.setFill(); platePath.fill()
// Lit white keys (drawn before dividers / black keys on top). let whites: [(Int, CGFloat, CGFloat)] = [(0, 0.254, 0.417), (2, 0.417, 0.580), (4, 0.580, 0.744)] for (idx, x0, x1) in whites where litKeys.contains(idx) { keyColors[idx].setFill() NSBezierPath(roundedRect: CGRect(x: x0 * px + 2, y: 0.428 * px + 2, width: (x1 - x0) * px - 4, height: 0.142 * px - 4), xRadius: px * 0.01, yRadius: px * 0.01).fill() }
// White-key dividers. edge.setStroke() for xf: CGFloat in [0.417, 0.580] { let p = NSBezierPath() p.move(to: CGPoint(x: xf * px, y: 0.432 * px)) p.line(to: CGPoint(x: xf * px, y: 0.566 * px)) p.lineWidth = px * 0.006 p.stroke() } // Plate outline. edge.setStroke(); platePath.lineWidth = px * 0.008; platePath.stroke()
// Black keys (lit → their tint, else black). let blacks: [(Int, CGFloat, CGFloat)] = [(1, 0.369, 0.463), (3, 0.535, 0.629)] for (idx, x0, x1) in blacks { (litKeys.contains(idx) ? keyColors[idx] : black).setFill() NSBezierPath(roundedRect: CGRect(x: x0 * px, y: 0.428 * px, width: (x1 - x0) * px, height: 0.098 * px), xRadius: px * 0.01, yRadius: px * 0.01).fill() } return true } }
/// The QR card. A white rounded "tile" (matching the icon's /// squircle corner curve) with the code inset by a quiet-zone /// margin so the rounding never clips the finder patterns. The /// shadow rides on the container via an explicit rounded /// `shadowPath` (the white face clips its own contents, which would /// otherwise clip the shadow too). private func configureQR(image: NSImage?) { qrCard.bounds = CGRect(x: 0, y: 0, width: cardSide, height: cardSide) qrCard.anchorPoint = CGPoint(x: 0.5, y: 0.5) qrCard.masksToBounds = false
// Inset the white tile by the same transparent margin the app // icon carries, so the QR's VISIBLE size matches the icon // squircle (the margin is now "counted" in the shared box). let margin = cardSide * artMarginRatio let faceRect = qrCard.bounds.insetBy(dx: margin, dy: margin) let r = faceRect.width * Self.cornerRatio applyCardShadow(qrCard, path: CGPath( roundedRect: faceRect, cornerWidth: r, cornerHeight: r, transform: nil))
let face = CALayer() face.frame = faceRect face.backgroundColor = NSColor.white.cgColor face.cornerRadius = r face.cornerCurve = .continuous face.masksToBounds = true
// Quiet-zone inset for the code within the white tile so the // rounded corners never clip the finder patterns. let code = CALayer() let pad = faceRect.width * 0.11 code.frame = CGRect(x: pad, y: pad, width: faceRect.width - pad * 2, height: faceRect.height - pad * 2) code.contents = image?.cgImage( forProposedRect: nil, context: nil, hints: nil) code.contentsGravity = .resizeAspect code.contentsScale = 2 face.addSublayer(code) qrCard.addSublayer(face) }
/// Offset drop shadow shared by both cards — the wg deck's /// `box-shadow: 4px 4px 12px rgba(0,0,0,.75)` feel. private func applyCardShadow(_ card: CALayer, path: CGPath?) { card.shadowColor = NSColor.black.cgColor card.shadowOpacity = 0.45 card.shadowRadius = 6 card.shadowOffset = CGSize(width: 3, height: -3) // down-right card.shadowPath = path card.masksToBounds = false }
/// Target center for whichever card is in front / behind. Hover is /// a gentle peek — just enough to show there are two cards. private func frontCenter(_ base: CGPoint) -> CGPoint { hovering ? CGPoint(x: base.x - 3, y: base.y - 2) : base } private func backCenter(_ base: CGPoint) -> CGPoint { let dx: CGFloat = hovering ? 13 : 6 let dy: CGFloat = hovering ? 9 : 5 return CGPoint(x: base.x + dx, y: base.y + dy) } private func backTilt() -> CGFloat { (hovering ? 6 : 3) * .pi / 180 }
// MARK: - Promo capture
/// The shuffle, expressed as a pure function of progress instead of a /// Core Animation sequence, so a headless promo render can capture it a /// frame at a time. /// /// `flip()` animates through the presentation layer, and `cacheDisplay` /// only ever sees model values — a captured frame would show the animation /// already finished. This walks the SAME path `startNextTuck` walks (out /// along `angle`, distance `cardSide * 1.25`, the counter-nudge, the two /// half-phases and their easing curves) and writes it straight to the /// model with actions off. `jitter` stands in for the shuffle's random /// exit angle so a render is reproducible. /// /// `progress` runs 0…1 across the whole two-phase shuffle. At 1 the swap /// is committed, exactly as the animation's completion block commits it. /// Seed which card starts in front. A capture process is born with the icon /// forward, so rendering the SECOND tap of a flicker — the QR shuffling back /// behind the icon — needs the deck posed the other way round first. func captureSetFront(icon: Bool) { guard bounds.width > 0 else { return } CATransaction.begin(); CATransaction.setDisableActions(true) frontIsIcon = icon relayout(animated: false) raise(icon ? iconCard : qrCard) CATransaction.commit() }
func captureScrubShuffle(progress: CGFloat, jitter: CGFloat = 0) { guard bounds.width > 0 else { return } let p = min(max(progress, 0), 1) let base = CGPoint(x: bounds.midX, y: bounds.midY) let moving = frontIsIcon ? iconCard : qrCard // current front → behind let staying = frontIsIcon ? qrCard : iconCard // current back → front
let angle = -CGFloat.pi / 2 + jitter let dist = cardSide * 1.25 let ux = cos(angle), uy = sin(angle) let out = CGPoint(x: base.x + ux * dist, y: base.y + uy * dist) let nudge = CGPoint(x: base.x - ux * cardSide * 0.14, y: base.y - uy * cardSide * 0.14)
CATransaction.begin(); CATransaction.setDisableActions(true) defer { CATransaction.commit() }
if p >= 1 { frontIsIcon.toggle() relayout(animated: false) raise(frontIsIcon ? iconCard : qrCard) return } if p < 0.5 { // Phase 1 — moving slides out over the top; staying rises and // nudges clear. Moving rides above everything until it's outside. let u = p / 0.5 place(moving, at: lerp(frontCenter(base), out, Self.easeIn(u)), rotation: 0, scale: 1.0 + (0.96 - 1.0) * Self.easeIn(u)) place(staying, at: lerp(backCenter(base), nudge, Self.easeOut(u)), rotation: backTilt() * (1 - Self.easeOut(u)), scale: 0.9 + 0.1 * Self.easeOut(u)) raise(moving) } else { // Phase 2 — moving returns to the behind slot; staying centers as // the new front. The z-swap already happened, clear of overlap. let u = (p - 0.5) / 0.5 place(moving, at: lerp(out, backCenter(base), Self.easeOut(u)), rotation: backTilt() * Self.easeOut(u), scale: 0.96 + (0.9 - 0.96) * Self.easeOut(u)) place(staying, at: lerp(nudge, frontCenter(base), Self.easeInEaseOut(u)), rotation: 0, scale: 1.0) raise(staying) } }
/// Put `card` on top. On screen the compositor sorts siblings by /// `zPosition`, but `cacheDisplay` — which is how the promo captures render /// — walks `sublayers` in array order and ignores it entirely. Reorder the /// array so an offline frame stacks the way the app does. private func raise(_ card: CALayer) { let other = (card === iconCard) ? qrCard : iconCard card.zPosition = 10 other.zPosition = 5 deck.sublayers = [other, card] }
private func place(_ card: CALayer, at center: CGPoint, rotation: CGFloat, scale: CGFloat) { card.position = center card.transform = CATransform3DScale( CATransform3DMakeRotation(rotation, 0, 0, 1), scale, scale, 1) }
private func lerp(_ a: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint { CGPoint(x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t) }
/// The three CAMediaTimingFunction curves the shuffle uses, as their /// documented control points, solved on the unit interval. private static func easeIn(_ t: CGFloat) -> CGFloat { bezier(t, 0.42, 0, 1, 1) } private static func easeOut(_ t: CGFloat) -> CGFloat { bezier(t, 0, 0, 0.58, 1) } private static func easeInEaseOut(_ t: CGFloat) -> CGFloat { bezier(t, 0.42, 0, 0.58, 1) }
/// Unit cubic bezier y-for-x, by bisection. Ten steps is well under a /// pixel at this scale and needs no derivative. private static func bezier(_ x: CGFloat, _ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat) -> CGFloat { func curve(_ t: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat { let m = 1 - t return 3 * m * m * t * a + 3 * m * t * t * b + t * t * t } var lo: CGFloat = 0, hi: CGFloat = 1, t: CGFloat = x for _ in 0..<10 { t = (lo + hi) / 2 if curve(t, x1, x2) < x { lo = t } else { hi = t } } return curve(t, y1, y2) }
// MARK: - Layout / animation
/// Settle both cards into their front/back slots with a spring, for /// rest ↔ hover transitions (and the initial non-animated layout). private func relayout(animated: Bool, springDamping: CGFloat = 18) { guard bounds.width > 0 else { return } let base = CGPoint(x: bounds.midX, y: bounds.midY) let front = frontIsIcon ? iconCard : qrCard let back = frontIsIcon ? qrCard : iconCard
settle(front, center: frontCenter(base), rotation: 0, scale: hovering ? 1.02 : 1.0, z: 10, shadowRadius: hovering ? 8 : 6, animated: animated, damping: springDamping) settle(back, center: backCenter(base), rotation: backTilt(), scale: hovering ? 0.92 : 0.9, z: 5, shadowRadius: hovering ? 8 : 6, animated: animated, damping: springDamping) }
/// Spring a card to a slot (position + transform), with the model /// values set so it rests there once the spring settles. private func settle(_ card: CALayer, center: CGPoint, rotation: CGFloat, scale: CGFloat, z: CGFloat, shadowRadius: CGFloat, animated: Bool, damping: CGFloat) { let transform = CATransform3DScale( CATransform3DMakeRotation(rotation, 0, 0, 1), scale, scale, 1) card.zPosition = z if animated { let pos = CASpringAnimation(keyPath: "position") pos.fromValue = card.presentation()?.position ?? card.position pos.toValue = center pos.damping = damping; pos.stiffness = 240; pos.mass = 1 pos.duration = pos.settlingDuration let tform = CASpringAnimation(keyPath: "transform") tform.fromValue = card.presentation()?.transform ?? card.transform tform.toValue = transform tform.damping = damping; tform.stiffness = 240; tform.mass = 1 tform.duration = tform.settlingDuration card.add(pos, forKey: "pos") card.add(tform, forKey: "tform") } card.position = center card.transform = transform card.shadowRadius = shadowRadius }}