diff --git a/slab/menuband/.gitignore b/slab/menuband/.gitignore index 1bac0b46c..2a31ff342 100644 --- a/slab/menuband/.gitignore +++ b/slab/menuband/.gitignore @@ -1,4 +1,5 @@ .build/ +.build-debug/ .swiftpm/ DerivedData/ AppIcon.iconset/ diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist index 1b57581ac..7a39d2370 100644 --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.9 + 0.9.1 CFBundleVersion - 9 + 91 LSMinimumSystemVersion 11.0 LSUIElement diff --git a/slab/menuband/README.md b/slab/menuband/README.md new file mode 120000 index 000000000..1e4721193 --- /dev/null +++ b/slab/menuband/README.md @@ -0,0 +1 @@ +SCORE.md \ No newline at end of file diff --git a/slab/menuband/SCORE.md b/slab/menuband/SCORE.md new file mode 100644 index 000000000..6e1f61dfb --- /dev/null +++ b/slab/menuband/SCORE.md @@ -0,0 +1,168 @@ +# Menu Band + +A macOS menubar piano. The status item draws a multi-octave keyboard +inline in the menu bar; clicks/key-presses play through CoreAudio + +optional CoreMIDI; a popover (gear chip → click) exposes the GM +instrument chooser, octave control, layout pickers, shortcut binders, +and a metronome. Paired with a floating "play palette" panel for the +expanded keyboard view + waveform visualizer. + +Lives at `slab/menuband/`. Distinct from `slab/menubar-swift/` (the +Claude session menubar; different status item, different process). + +## Layout + +``` +slab/menuband/ +├── Package.swift SwiftPM manifest. No external deps — +│ uses AppKit + Carbon hotkeys + AVFoundation. +├── install.sh Production build. Compiles release → +│ wraps in .app bundle → signs with +│ Developer ID → loads launchd agent. +├── Info.plist Bundle metadata (LSUIElement = YES). +├── MenuBand.entitlements Hardened Runtime, no exceptions. +├── computer.aestheticcomputer.menuband.plist.tmpl +│ launchd template (KeepAlive, RunAtLoad). +├── Sources/MenuBand/ +│ ├── main.swift Entry point. setActivationPolicy(.accessory). +│ ├── AppDelegate.swift Status item, hotkeys, popover lifecycle, +│ │ click-away monitors. +│ ├── MenuBandController.swift +│ │ Audio/MIDI engine + state machine +│ │ (octave, instrument, key map, etc.). +│ ├── MenuBandPopover.swift All popover UI (~2000 LOC). Iterated on +│ │ most often → primary watch-reload target. +│ ├── PianoWaveformWindow/ Floating "play palette" panel. +│ │ Liquid-glass NSPanel; collapsed strip + +│ │ expanded keyboard/visualizer. +│ ├── KeyboardIconRenderer.swift +│ │ Status-item icon drawing (the inline +│ │ piano keys). Hit-testing for clicks. +│ ├── Localization.swift EN/ES tables + .didChange notification +│ │ that triggers full popover rebuild. +│ ├── GarageBandLibrary.swift / GeneralMIDI.swift +│ │ GM patch metadata + display names. +│ └── (helpers) Hover, hotkeys, MIDI, synth, shortcuts. +├── bin/ +│ ├── dev.sh Fast debug build + run (no signing). +│ ├── watch-reload.sh Auto rebuild + restart on save. +│ ├── instrument-cards.mjs FLUX → IG-square mnemonic deck builder. +│ └── instrument-card-compose.py +└── assets/ + └── instrument-cards/ Generated 1:1 instrument deck. +``` + +## Build + run + +### Production (signed, launchd-managed) + +```bash +./install.sh +``` + +What it does: +1. `swift build -c release`. +2. Wraps the binary in `~/Applications/Menu Band.app`. +3. Signs with Developer ID Application (or self-signed if absent). +4. Writes `~/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist`. +5. `launchctl load`s the plist (KeepAlive + RunAtLoad). + +Logs: +```bash +tail -f /tmp/menuband.err # stderr +tail -f /tmp/menuband.out # stdout +``` + +### Iterate on UI: watch-reload (recommended) + +For popover/UI iteration. Save a `.swift` file → automatic rebuild + +relaunch + popover reopens. Cycle is ~2-5 seconds for incremental +builds. State that survives the restart: nothing inside the process, +but the popover auto-reopens via the existing +`computer.aestheticcomputer.menuband.showPopover` distributed +notification, so it *feels* immediate for visual iteration. + +```bash +./bin/watch-reload.sh # watch all Sources/ +./bin/watch-reload.sh popover # narrow to popover + Localization +``` + +Requires `fswatch` (`brew install fswatch`). + +### Iterate on logic: dev.sh (manual restart, faster build) + +```bash +./bin/dev.sh +``` + +What it does: +1. Stops the launchd-managed production daemon. +2. Builds debug (no optimization, faster) and runs the unsigned binary + directly via `swift run -c debug --scratch-path .build-debug`. +3. No bundle wrapping, no signing, no launchd — just the binary. + +After Ctrl-C, run `./install.sh` to put the signed daemon back. +Subsequent re-runs of `dev.sh` rebuild incrementally (~2-5s). + +## Why no hot-reload + +Tried `johnno1962/HotReloading` and `johnno1962/InjectionLite` (the +SwiftPM-friendly variant) for true in-place dylib injection. Both +fundamentally rely on parsing Xcode's `.xcactivitylog` files to derive +per-file compile commands. SwiftPM emits `.build/debug.yaml` instead, +which the injector doesn't read. The InjectionLite class loads into +the binary fine, but its `LogParser` finds no logs and bails silently. + +Workarounds available, none clean: +- Wrap the project in an `.xcodeproj`, build it once via Xcode, then + let InjectionLite pick up the resulting xcactivitylog. Means + maintaining a parallel Xcode project alongside Package.swift. +- Write a custom in-process file watcher that runs `swift build` and + manually loads the resulting object file via `dlopen`. Real work. + +For now, `watch-reload.sh` is the iteration path. ~2-5s per cycle +with auto-reopen makes it feel close enough to live-reload for the +popover work that takes most of the iteration time. + +## Architectural notes for agents + +**Singletons.** `popover` is a single `NSPopover`. The expanded floating +keyboard is a single `PianoWaveformPanel` managed by +`PianoWaveformWindowDelegate`. If you see "two liquid-glass things on +screen", it's the popover + the panel both rendering, not duplicates of +either. `toggleFocusCaptureFromShortcut` (AppDelegate.swift) defers +panel show by one runloop tick when the popover was open so AppKit can +finish tearing it down before the panel materializes. + +**Popover behavior.** `popover.behavior = .applicationDefined` because +clicks on the menubar piano keys would count as "outside" the popover +under `.transient` and dismiss it during play. Custom click-away +monitor in `AppDelegate.closePopover` handles dismissal manually. + +**Language-change rebuild path.** Posting `Localization.didChange` +triggers `rebuildPopoverForLanguageChange()`, which closes the popover, +runs `installPopoverVC()` to build a fresh VC against the new locale, +and reopens it if it was visible. Same path runs every time +`watch-reload.sh` reposts the showPopover notification after relaunch. + +**Hardened Runtime.** Release builds opt into Hardened Runtime *without* +exceptions. CoreMIDI, AVAudioEngine, CGEventTap, and Carbon +`RegisterEventHotKey` all work under default Hardened Runtime. Don't +add exceptions unless you've demonstrated they're required. + +## Common tasks + +| Task | Command | +| --- | --- | +| Build + reinstall signed app | `./install.sh` | +| Auto rebuild + restart on save | `./bin/watch-reload.sh` | +| Manual debug build + run | `./bin/dev.sh` | +| Tail logs | `tail -f /tmp/menuband.err` | +| Force kill running daemon | `pkill -f "/MenuBand$"` | +| Stop launchd daemon | `launchctl unload ~/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist` | +| Generate instrument cards | `node bin/instrument-cards.mjs` | + +## Notation + +This file is the source of truth. `README.md` is a symlink to it so +GitHub renders it as the project landing page. diff --git a/slab/menuband/Sources/MenuBand/AboutWindow.swift b/slab/menuband/Sources/MenuBand/AboutWindow.swift new file mode 100644 index 000000000..3082d15a9 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/AboutWindow.swift @@ -0,0 +1,239 @@ +import AppKit + +/// 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 { + private var flashTimer: Timer? + private weak var flashButton: NSButton? + private var flashOn = false + private let updateInfo: UpdateChecker.VersionInfo? + + init(updateInfo: UpdateChecker.VersionInfo?) { + self.updateInfo = updateInfo + 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. + window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) + super.init(window: window) + window.delegate = self + buildContent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not used") + } + + deinit { + flashTimer?.invalidate() + } + + func present() { + guard let window = window else { return } + 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 = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + startFlashingIfNeeded() + } + + func windowWillClose(_ notification: Notification) { + flashTimer?.invalidate() + flashTimer = nil + } + + // MARK: - Layout + + private func buildContent() { + guard let window = window else { return } + let content = NSView() + content.translatesAutoresizingMaskIntoConstraints = false + window.contentView = content + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 18, 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 — try the bundled artwork first, then walk up to + // a known repo location for `swift run` dev builds where + // there's no .app bundle to host AppIcon.icns. + if let icon = Self.loadAppIcon() { + let view = NSImageView(image: icon) + view.imageScaling = .scaleProportionallyUpOrDown + view.translatesAutoresizingMaskIntoConstraints = false + view.widthAnchor.constraint(equalToConstant: 96).isActive = true + view.heightAnchor.constraint(equalToConstant: 96).isActive = true + stack.addArrangedSubview(view) + } + + let nameLabel = NSTextField(labelWithString: "Menu Band") + nameLabel.font = NSFont.systemFont(ofSize: 18, weight: .bold) + nameLabel.alignment = .center + stack.addArrangedSubview(nameLabel) + + let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?" + let build = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "?" + // Mac-standard version line: "Version 0.9 (9)" — full + // marketing version + build number in parentheses. + let versionString = build == version ? "Version \(version)" : "Version \(version) (\(build))" + let versionLabel = NSTextField(labelWithString: versionString) + versionLabel.font = NSFont.systemFont(ofSize: 11) + versionLabel.textColor = .secondaryLabelColor + versionLabel.alignment = .center + stack.addArrangedSubview(versionLabel) + + stack.setCustomSpacing(14, after: versionLabel) + + // 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.preferredMaxLayoutWidth = 264 + stack.addArrangedSubview(body) + + // Aesthetic.Computer chip — purple words flanking a pink dot, the + // same brand badge that used to live inline in the popover. + let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) + let acLink = MenuBandPopoverViewController.makeLinkButton( + attr: MenuBandPopoverViewController.aestheticComputerTitle(), + target: self, + action: #selector(openAesthetic), + background: acPurple.withAlphaComponent(0.14), + border: acPurple.withAlphaComponent(0.55) + ) + stack.addArrangedSubview(acLink) + + if let info = updateInfo, + UpdateChecker.isNewer(info.version, than: UpdateChecker.currentVersion()) { + stack.setCustomSpacing(16, after: acLink) + let btn = NSButton(title: "New Menu Band 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 + } + } + + // MARK: - Icon loader + + /// Look for the app icon, falling back to the repo's + /// `AppIcon.icns` when running via `swift run` (no .app bundle). + private static func loadAppIcon() -> NSImage? { + if let icon = NSImage(named: NSImage.applicationIconName), + icon.size != .zero { + return icon + } + // SwiftPM debug binary lives under .build//debug/MenuBand. + // Walk up to the project root and grab the source icon. + let exec = Bundle.main.executablePath ?? "" + let url = URL(fileURLWithPath: exec) + let candidates = [ + url.deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("AppIcon.icns"), + url.deletingLastPathComponent() + .appendingPathComponent("AppIcon.icns"), + ] + for candidate in candidates { + if FileManager.default.fileExists(atPath: candidate.path), + let img = NSImage(contentsOf: candidate) { + return img + } + } + return nil + } + + // MARK: - Actions + + @objc private func openAesthetic() { + // Open inside Menu Band's own glass-chromed webview instead + // of bouncing to Safari — same Mac-native desktop feel as + // the ac-electron app, but right out of the menubar piano. + // Anchor the AC window to the About window's right edge so + // they pair side-by-side instead of stacking on top of each + // other. + AestheticWebWindowController.showOrFocus(rightOf: window?.frame) + } + + @objc private func openUpdateLink() { + if let url = URL(string: "https://prompt.ac/menuband") { + NSWorkspace.shared.open(url) + } + } + + // 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 attr = NSMutableAttributedString( + string: "New Menu Band Available!", + 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 + } +} diff --git a/slab/menuband/Sources/MenuBand/AestheticWebWindow.swift b/slab/menuband/Sources/MenuBand/AestheticWebWindow.swift new file mode 100644 index 000000000..86bf8f520 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/AestheticWebWindow.swift @@ -0,0 +1,268 @@ +import AppKit +import WebKit + +/// Liquid-glass webview window that loads https://aesthetic.computer — +/// brings the AC site onto a Mac user's desktop the same way the +/// ac-electron app does, but right out of Menu Band so it works the +/// instant the menubar piano launches. Singleton: a second invocation +/// raises the existing window instead of opening a new one. +final class AestheticWebWindowController: NSWindowController, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { + private static var shared: AestheticWebWindowController? + private static let defaultURL = URL(string: "https://aesthetic.computer")! + /// JS bridge name — the page calls + /// `window.webkit.messageHandlers.acClose.postMessage(...)` + /// (or just emits the existing `ac-close-window` postMessage that + /// the injected userscript forwards) to dismiss the window. + private static let closeMessageName = "acClose" + + private var acWebView: WKWebView! + private weak var glassView: NSView? + + /// Show the AC web window, creating it on first call. Subsequent + /// calls focus the existing window instead of layering + /// duplicates. Pass `rightOf:` (typically the About window's + /// frame) to anchor the panel to the right of an existing + /// window — keeps the two side-by-side instead of stacking. + @discardableResult + static func showOrFocus(rightOf anchor: NSRect? = nil, + gap: CGFloat = 12) -> AestheticWebWindowController { + let controller = shared ?? AestheticWebWindowController() + if shared == nil { shared = controller } + if let anchor = anchor, let win = controller.window { + controller.position(rightOf: anchor, gap: gap, window: win) + } + controller.showWindow(nil) + controller.window?.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + return controller + } + + private func position(rightOf anchor: NSRect, gap: CGFloat, window: NSWindow) { + let myFrame = window.frame + let screenFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? anchor + var x = anchor.maxX + gap + // Don't run off the right edge — flop to the left of the + // anchor if the screen can't fit us on the right. + if x + myFrame.width > screenFrame.maxX { + let leftCandidate = anchor.minX - gap - myFrame.width + if leftCandidate >= screenFrame.minX { + x = leftCandidate + } else { + x = max(screenFrame.minX, + screenFrame.maxX - myFrame.width) + } + } + let y = anchor.minY + (anchor.height - myFrame.height) / 2 + let clampedY = min(max(y, screenFrame.minY), + screenFrame.maxY - myFrame.height) + window.setFrameOrigin(NSPoint(x: x, y: clampedY)) + } + + init() { + // No `.closable` — the AC prompt's `-` command is the way out + // (matches the ac-electron flip-view contract). We still hand + // the OS Cmd+W via menu so power users have an escape hatch. + let style: NSWindow.StyleMask = [ + .titled, .miniaturizable, .resizable, .fullSizeContentView, + ] + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 300), + styleMask: style, + backing: .buffered, + defer: false + ) + // Tiny corner panel — sits on top of everything, follows the + // user across Spaces, never large enough to feel like a + // browser. Pairs with the menubar piano as a second + // instrument-shaped surface. + win.minSize = NSSize(width: 280, height: 200) + win.level = .statusBar + win.collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + .stationary, + .ignoresCycle, + ] + win.title = "aesthetic.computer" + win.titlebarAppearsTransparent = true + win.titleVisibility = .hidden + win.isMovableByWindowBackground = false + win.isReleasedWhenClosed = false + win.center() + win.setFrameAutosaveName("AestheticWebWindow") + // Transparent window backing so the glass beneath the + // webview can actually peek through where AC has alpha + // pixels (loading screens, transparent overlays). + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = true + + super.init(window: win) + win.delegate = self + // Hide every traffic-light — the AC prompt's `-` command is + // the only way out; no close, minimize, or zoom chrome to + // distract from the embedded site. + for kind: NSWindow.ButtonType in [.closeButton, .miniaturizeButton, .zoomButton] { + win.standardWindowButton(kind)?.isHidden = true + } + installContent(in: win) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + + private func installContent(in window: NSWindow) { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + container.wantsLayer = true + // Soft rounded corners so the chrome reads as a unified + // panel even though it's a normal NSWindow underneath. + container.layer?.cornerRadius = 14 + container.layer?.masksToBounds = true + if #available(macOS 10.15, *) { + container.layer?.cornerCurve = .continuous + } + + // Glass background — only meaningful on macOS 26+. Older + // OSes get a flat dark backdrop so the webview still has a + // legible bed when AC is loading. + if #available(macOS 26.0, *) { + let glass = AestheticWebGlassEffectView() + glass.cornerRadius = 14 + glass.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(glass) + NSLayoutConstraint.activate([ + glass.leadingAnchor.constraint(equalTo: container.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: container.trailingAnchor), + glass.topAnchor.constraint(equalTo: container.topAnchor), + glass.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + self.glassView = glass + } else { + container.layer?.backgroundColor = NSColor.black + .withAlphaComponent(0.85).cgColor + } + + let config = WKWebViewConfiguration() + config.preferences.javaScriptCanOpenWindowsAutomatically = false + // Native bridge for the AC prompt's `-` close command. + // Mirrors ac-electron's webview-preload.js — the page posts + // `{ type: 'ac-close-window' }` (and we also expose + // `window.acElectron.closeWindow()` for parity), the + // injected userscript forwards it to the native handler, + // which closes this window. + let userController = WKUserContentController() + userController.add(self, name: Self.closeMessageName) + userController.addUserScript(WKUserScript( + source: Self.closeBridgeScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + )) + config.userContentController = userController + let webView = WKWebView(frame: .zero, configuration: config) + webView.translatesAutoresizingMaskIntoConstraints = false + webView.navigationDelegate = self + webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.allowsMagnification = false + webView.customUserAgent = Self.userAgent() + // Make the webview transparent so the glass shows through + // wherever the AC page renders alpha pixels. `drawsBackground` + // is a long-stable WKWebView KVC key on macOS. + webView.setValue(false, forKey: "drawsBackground") + // Lower the perceived screen density so AC packs tighter + // into the small panel — pageZoom < 1 shrinks the page's + // CSS-pixel footprint to match the compact window size. + webView.pageZoom = 0.75 + container.addSubview(webView) + + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + self.acWebView = webView + window.contentView = container + webView.load(URLRequest(url: Self.defaultURL)) + } + + private static func userAgent() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + return "AestheticComputerMenuBand/\(version) (Macintosh)" + } + + /// JS shim injected at document-start so AC prompts can close + /// the window the way they already close ac-electron windows: + /// either `window.postMessage({ type: 'ac-close-window' }, '*')` + /// or `window.acElectron.closeWindow()`. + private static let closeBridgeScript: String = """ + (function () { + function relayClose() { + try { window.webkit.messageHandlers.acClose.postMessage('close'); } + catch (_) {} + } + window.addEventListener('message', function (e) { + if (e && e.data && e.data.type === 'ac-close-window') relayClose(); + }); + // Mirror ac-electron/webview-preload.js's API surface so AC + // pieces detect "we're embedded" and use the same close hook. + if (!window.acElectron) { + window.acElectron = { + isElectron: false, + isMenuBand: true, + platform: 'darwin', + closeWindow: relayClose, + }; + } else if (typeof window.acElectron.closeWindow !== 'function') { + window.acElectron.closeWindow = relayClose; + } + })(); + """ + + // MARK: - WKScriptMessageHandler + + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard message.name == Self.closeMessageName else { return } + // Drop the singleton + tear down explicitly — orderOut keeps + // the close path snappy (no hide animation) and matches what + // a closeButton click would do under `.closable` style. + window?.orderOut(nil) + window?.close() + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + // Drop the singleton so the next showOrFocus() rebuilds the + // window fresh — keeps state simple and avoids a half-torn + // webview lingering after close. + if Self.shared === self { + Self.shared = nil + } + } + + // MARK: - WKUIDelegate + + /// Open links targeting `_blank` in the user's default browser + /// rather than spawning new in-app webviews. Keeps the embedded + /// experience focused on AC itself. + func webView(_ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + if let url = navigationAction.request.url { + NSWorkspace.shared.open(url) + } + return nil + } +} + +@available(macOS 26.0, *) +final class AestheticWebGlassEffectView: NSGlassEffectView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index aa39c5a5a..9ef0cd713 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -11,8 +11,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var focusCaptureHotkey: GlobalHotkey? private var pianoWaveformHotkey: GlobalHotkey? private var layoutToggleHotkey: GlobalHotkey? - private let popover = NSPopover() + private var popoverPanel: MenuBandPopoverPanel? private var popoverVC: MenuBandPopoverViewController? + + private var isPopoverPanelShown: Bool { popoverPanel?.isVisible == true } private lazy var pianoWaveformWindowDelegate = PianoWaveformWindowDelegate(menuBand: menuBand) private var appBeforePopover: NSRunningApplication? private var appBeforeFocusCapture: NSRunningApplication? @@ -152,7 +154,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Refresh the popover too so live state changes // (octave shift via , / . , MIDI mode flip, etc.) // reflect immediately while the popover is open. - if self.popover.isShown { + if self.isPopoverPanelShown { self.popoverVC?.syncFromController() } } @@ -191,8 +193,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // isn't visible, falling the panel back to the menubar // status-item anchor. pianoWaveformWindowDelegate.popoverFrameProvider = { [weak self] in - guard let self = self, self.popover.isShown else { return nil } - return self.popover.contentViewController?.view.window?.frame + guard let self = self, self.isPopoverPanelShown else { return nil } + return self.popoverPanel?.frame } pianoWaveformWindowDelegate.isPianoFocusActive = { [weak self] in self?.localCapture.isArmed ?? false @@ -292,7 +294,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // never falls through to the focused app or to the note // path (where space would otherwise behave like an // unmapped key consume). - if keyCode == 49 /* kVK_Space */, self.popover.isShown { + if keyCode == 49 /* kVK_Space */, self.isPopoverPanelShown { if isDown && !isRepeat { self.popoverVC?.toggleMetronome() } @@ -328,9 +330,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags ) if consumed && isDown { - if !self.menuBand.litNotes.isEmpty { - self.pianoWaveformWindowDelegate.showIfNeeded() - } + // Note plays only — the floating panel is reserved + // for explicit triggers (LED chip / gear popover). + // Earlier this called `showIfNeeded()` so a typed + // letter would auto-open the panel; the surprise + // pop-up was not what the user wanted while playing. // Use the most-recent lit display note as the wave pivot // so the ripple emanates from whichever key the user just // played. `litNotes` is updated synchronously on this @@ -346,17 +350,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.finishLocalCapture(reason: reason) } - // Pre-instance the popover + force its view to load now so the - // first click pops it instantly. With `animates = false` the - // open/close has no transition — it's a snap, much more "playable" - // for quickly toggling between the menubar piano and the picker. + // Pre-instance the popover VC + force its view to load now so the + // first click pops it instantly. The actual NSPanel host is + // built lazily in `showPopover()` (panel position depends on + // the floating piano panel's frame, which only exists once the + // status item is on screen). installPopoverVC() - // .applicationDefined: never auto-close. We manage closing manually - // so clicking a menubar piano key (which would normally count as - // "outside" the popover under .transient) doesn't dismiss the - // popover while the user is playing. - popover.behavior = .applicationDefined - popover.animates = false // Language change → rebuild the popover with the new translations. // Cheaper than walking every label with a setter, and means future @@ -393,7 +392,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func installPopoverVC() { let vc = MenuBandPopoverViewController() vc.menuBand = menuBand - vc.popover = popover vc.onFocusShortcutChange = { [weak self] shortcut in self?.applyFocusShortcut(shortcut) ?? false } @@ -413,7 +411,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.pianoWaveformWindowDelegate.isShown ?? false } popoverVC = vc - popover.contentViewController = vc _ = vc.view } @@ -421,15 +418,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// re-reads from the current locale. Preserves whether the popover was /// open — re-shows it relative to the status item if so. private func rebuildPopoverForLanguageChange() { - let wasShown = popover.isShown - if wasShown { popover.performClose(nil) } + let wasShown = isPopoverPanelShown + if wasShown { closePopover() } installPopoverVC() popoverVC?.syncFromController() - if wasShown, let button = statusItem.button { - popover.show(relativeTo: button.bounds, - of: button, - preferredEdge: .minY) - } + if wasShown { showPopover() } } // MARK: - Global shortcuts @@ -585,9 +578,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // popover closed, `popoverFrameProvider` returns nil and // `expandedFrame` falls back to a `centeredOrigin`, so the // panel pops up dead-center. + let popoverWasOpen = isPopoverPanelShown + let panelWasOpen = pianoWaveformWindowDelegate.isShown closePopover() - if pianoWaveformWindowDelegate.isShown { + if panelWasOpen { pianoWaveformWindowDelegate.dismiss(reason: .programmatic) + return + } + // Defer the panel open by one runloop tick so AppKit can finish + // tearing down the popover window first. Without this, the popover + // and the expanded panel both render on screen simultaneously + // (both wear liquid-glass material → reads as "two large popovers"). + if popoverWasOpen { + DispatchQueue.main.async { [weak self] in + self?.pianoWaveformWindowDelegate.showExpandedForPopover() + } } else { pianoWaveformWindowDelegate.showExpandedForPopover() } @@ -1267,20 +1272,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { displayNote: startDisplayNote, linger: initialShift ) - // Menubar piano taps no longer auto-open the floating panel — - // it's reserved for the popover-paired flow (and the explicit - // mini-visualizer chip / shortcut). Earlier this called - // showIfNeeded here, which surprised users who just wanted to - // play a quick note in the menubar. - // Arm sandbox-friendly local capture on a real piano click. We - // skip arming when global TYPE mode is already on — the global - // tap is already handling keys, doubling up would re-trigger - // every note. No letter flash on click: the label overlay is - // reserved for actual key presses, so the menubar stays clean - // when you're just tapping the piano with the mouse. + // Menubar piano taps play the note ONLY — no floating + // panel pop-up. Reserve the panel for the popover-paired + // flow and the explicit LED-chip / shortcut entry points. + // Arm sandbox-friendly local capture on a real piano click; + // skip arming when global TYPE mode is already on so the + // global tap doesn't double-trigger. if !menuBand.typeMode { localCapture.arm() - updatePianoWaveformWindow() + // Refresh the panel's lit state in case it's already + // visible (popover-paired), but do not call + // `updatePianoWaveformWindow()` — that path runs + // `showIfNeeded()` which would auto-open the panel + // here, which is exactly what the user doesn't want. + pianoWaveformWindowDelegate.refresh() } var currentDisplay: UInt8? = startDisplayNote var currentPlayed: UInt8? = startNote @@ -1337,9 +1342,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// `statusClicked` (settings-chip toggle) and from the click-away /// monitor itself when the user clicks anywhere outside our app. private func closePopover() { - if popover.isShown { - popover.performClose(nil) + if let panel = popoverPanel { + // Reparent the VC's view OUT of the panel before tearing + // it down so the next show can re-embed it cleanly. + popoverVC?.view.removeFromSuperview() + panel.orderOut(nil) } + popoverPanel = nil if let m = clickAwayMonitor { NSEvent.removeMonitor(m); clickAwayMonitor = nil } if let m = popoverEscMonitor { NSEvent.removeMonitor(m); popoverEscMonitor = nil } appBeforePopover = nil @@ -1355,55 +1364,88 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func handleShowPopoverNotification(_ note: Notification) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - if !self.popover.isShown { + if !self.isPopoverPanelShown { self.showPopover() } } } private func showPopover() { - guard let button = statusItem.button else { return } - // popoverVC is pre-built in applicationDidFinishLaunching so the first - // open is instant — no lazy view inflation here. + guard let button = statusItem.button, + let buttonWindow = button.window else { return } popoverVC?.syncFromController() - if popover.isShown { + if isPopoverPanelShown { closePopover() } else { + guard let vc = popoverVC else { return } + // Force the VC's view to lay out so we have its real + // preferredContentSize before stuffing it into the panel. + _ = vc.view + vc.view.layoutSubtreeIfNeeded() + let contentSize = vc.preferredContentSize.width > 0 + ? vc.preferredContentSize + : vc.view.fittingSize + + let panel = MenuBandPopoverPanel( + content: vc.view, + contentSize: contentSize) + + // Show the floating piano FIRST so its frame is known and + // we can flush the popover's left edge against the + // floating panel's right edge. + pianoWaveformWindowDelegate.showCollapsedForPopover() + updatePianoWaveformWindowSuppression() + + // Compute screen positions: + // • leftScreenX = right edge of the floating piano panel + // (so the popover sits flush against it) + // • topScreenY = bottom of the menubar + // • arrowScreenX = horizontal center of the gear/note icon let imgSize = KeyboardIconRenderer.imageSize let bb = button.bounds let xOff = (bb.width - imgSize.width) / 2.0 - let yOff = (bb.height - imgSize.height) / 2.0 let latch = KeyboardIconRenderer.settingsRectPublic - let anchor = NSRect( - x: xOff + latch.minX, - y: yOff + latch.minY, - width: latch.width, - height: latch.height - ) - // Activate the app + make the popover key so hover and clicks - // register immediately. NSStatusItem popovers don't pull focus - // by default; without this you have to click into the popover - // once before its controls react. + let gearLocal = NSPoint(x: xOff + latch.midX, y: 0) + let gearWindow = button.convert(gearLocal, to: nil) + let gearScreen = buttonWindow.convertPoint(toScreen: gearWindow) + let buttonScreenFrame = buttonWindow.convertToScreen(button.frame) + let topScreenY = buttonScreenFrame.minY + + // Always anchor so the arrow tip lands a single corner-inset + // from the popover's LEFT edge — the popover then extends + // rightward from the gear, regardless of where the floating + // piano panel happens to sit. Anchoring to `pianoFrame.maxX` + // (the older behavior) could push the popover's left edge + // past the gear when the piano panel was wide, which forced + // the arrow to clamp at the corner instead of pointing at + // the gear icon. + let leftScreenX: CGFloat = gearScreen.x + - MenuBandPopoverPanel.cornerRadius + - MenuBandPopoverPanel.arrowWidth / 2 - 2 + + panel.position( + leftScreenX: leftScreenX, + topScreenY: topScreenY, + arrowScreenX: gearScreen.x) + + // Pair-and-show: panel goes up first, then we record the + // ownership reference + arm focus/click-away monitors. let frontmost = NSWorkspace.shared.frontmostApplication appBeforePopover = frontmost?.bundleIdentifier == Bundle.main.bundleIdentifier ? nil : frontmost NSApp.activate(ignoringOtherApps: true) - popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) - // Pair the popover with the COLLAPSED floating panel — - // that's where the GM chooser lives. Expanded is only - // reachable by user action (the expand button on the - // collapsed panel). - pianoWaveformWindowDelegate.showCollapsedForPopover() - updatePianoWaveformWindowSuppression() + panel.makeKeyAndOrderFront(nil) + popoverPanel = panel + // Arm local key capture so arrow keys + spacebar reach // our handler while the popover is up. The InstrumentList // used to be in the popover and grabbed keys via its // first-responder; once it moved to the floating panel // nothing was capturing arrows for the controller. if !localCapture.isArmed { localCapture.arm() } - DispatchQueue.main.async { - self.popover.contentViewController?.view.window?.makeKey() + DispatchQueue.main.async { [weak panel] in + panel?.makeKey() } // Click-away monitor: clicks on OTHER apps close the popover. // In-app clicks (status item button, the popover itself) don't @@ -1437,14 +1479,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func updatePianoWaveformWindow() { pianoWaveformWindowDelegate.refresh() guard pianoWaveformWindowDelegate.isCollapsedState else { return } - if !menuBand.litNotes.isEmpty { - pianoWaveformWindowDelegate.showIfNeeded() - } else if !popover.isShown { - // Auto-hide the collapsed strip only when it's standalone. - // While the popover is up the panel is paired with it and - // must stay visible — scheduleHide would otherwise fire - // ~2s after any silent state change (e.g., arrow-key - // stepping) and dismiss the panel mid-popover. + // Show is intentionally NOT triggered here — `onChange` + // fires for every menubar piano tap, and auto-opening on + // a click was the source of the surprise pop-up. The panel + // stays a deliberate-trigger surface (LED chip, gear + // popover, typed-key showIfNeeded). All this path does is + // manage hide-timer state for an already-visible panel. + if menuBand.litNotes.isEmpty && !isPopoverPanelShown { + // Auto-hide the collapsed strip only when it's + // standalone. While the popover is up the panel is + // paired with it and must stay visible. pianoWaveformWindowDelegate.scheduleHide() } else { pianoWaveformWindowDelegate.cancelPendingHide() @@ -1453,6 +1497,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func updatePianoWaveformWindowSuppression() { pianoWaveformWindowDelegate.isCollapsedPresentationSuppressed = - pianoWaveformWindowDelegate.isDocked && (popover.isShown || pianoWaveformWindowDelegate.isShown) + pianoWaveformWindowDelegate.isDocked && (isPopoverPanelShown || pianoWaveformWindowDelegate.isShown) } } diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index d93ca10a6..341a60a9a 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -22,13 +22,28 @@ final class InstrumentListView: NSView { static let rows = 16 static let cellW: CGFloat = 28 static let cellH: CGFloat = 14 + /// Special "instrument 0" addressable slot that lives above the + /// 1-128 grid. Picking it engages MIDI passthrough mode (notes go + /// out the virtual port instead of the internal synth). Replaces + /// the old in-popover MIDI toggle switch with a visual peer of the + /// patch slots, so the chooser is the single addressing surface. + static let midiOutH: CGFloat = 18 + static let midiOutGap: CGFloat = 3 static let preferredWidth: CGFloat = cellW * CGFloat(cols) // 224 - static let preferredHeight: CGFloat = cellH * CGFloat(rows) // 224 + static let preferredHeight: CGFloat = midiOutH + midiOutGap + + cellH * CGFloat(rows) // 245 var selectedProgram: UInt8 = 0 { didSet { needsDisplay = true } } private(set) var hoveredProgram: UInt8? + /// Lit when the controller's `midiMode` is on. Drives the + /// MIDI-OUT cell's filled/outlined appearance and tints the + /// rest of the grid as deselected. + var midiModeActive: Bool = false { didSet { needsDisplay = true } } var onCommit: ((Int) -> Void)? + /// Fires when the user clicks the MIDI-OUT cell at the top of the + /// grid. The popover wires this to `menuBand.toggleMIDIMode()`. + var onMidiOutCommit: (() -> Void)? /// Fires whenever the hovered cell changes (including transitions to /// "no hover" → nil). Drives the controller's hover-preview note for /// sonic browsing. @@ -47,24 +62,6 @@ final class InstrumentListView: NSView { private var trackingArea: NSTrackingArea? - // MARK: - Visualizer state - /// One smoothed display level per column (0…1), driving the - /// per-column LED bars that bloom outward from the grid midline. - private var columnPeaks = [Float](repeating: 0, count: cols) - /// Per-tick raw RMS per column, before gain + smoothing. - private var columnLevels = [Float](repeating: 0, count: cols) - /// Reusable buffer the synth fills during snapshotWaveform. - private var sampleScratch = [Float](repeating: 0, count: 1024) - /// Auto-gain envelope. - private var smoothedPeak: Float = 0.05 - /// Slow blink phase for the selected cell — independent of audio - /// so the chosen instrument still breathes during silence. - private var blinkPhase: Double = 0 - private var visualizerLink: CVDisplayLink? - private var hasCaptureLease = false - private var pendingTickLock = NSLock() - private var pendingTick = false - override var isFlipped: Bool { true } // top-down rows, reading order /// Cells are clickable + drag-target — let `panel.isMovableByWindowBackground` /// kick in only on truly empty surfaces, not on the chooser. @@ -76,106 +73,6 @@ final class InstrumentListView: NSView { } required init?(coder: NSCoder) { fatalError() } - deinit { stopVisualizer() } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if window == nil { - stopVisualizer() - } else { - startVisualizer() - } - } - - private func startVisualizer() { - guard visualizerLink == nil, menuBand != nil else { return } - var link: CVDisplayLink? - guard CVDisplayLinkCreateWithActiveCGDisplays(&link) == kCVReturnSuccess, - let link = link else { return } - let opaque = Unmanaged.passUnretained(self).toOpaque() - CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx -> CVReturn in - guard let ctx = ctx else { return kCVReturnSuccess } - let view = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - // Coalesce pending ticks the same way WaveformView does — slow - // main runloop shouldn't build a backlog of stale draws. - view.pendingTickLock.lock() - if view.pendingTick { view.pendingTickLock.unlock(); return kCVReturnSuccess } - view.pendingTick = true - view.pendingTickLock.unlock() - DispatchQueue.main.async { view.tickVisualizer() } - return kCVReturnSuccess - }, opaque) - guard CVDisplayLinkStart(link) == kCVReturnSuccess else { return } - menuBand?.setWaveformCaptureEnabled(true) - hasCaptureLease = true - visualizerLink = link - } - - private func stopVisualizer() { - if let link = visualizerLink { - CVDisplayLinkStop(link) - visualizerLink = nil - } - if hasCaptureLease { - menuBand?.setWaveformCaptureEnabled(false) - hasCaptureLease = false - } - pendingTickLock.lock() - pendingTick = false - pendingTickLock.unlock() - for c in 0.. 0 else { return } - var framePeak: Float = 0 - for c in 0.. framePeak { framePeak = rms } - } - if framePeak > smoothedPeak { - smoothedPeak = framePeak - } else { - smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) - } - let gain = 0.95 / smoothedPeak - let attack: Float = 0.55 - let decay: Float = 0.18 - var changed = false - for c in 0.. prev) ? attack : decay - let next = prev * (1.0 - alpha) + raw * alpha - if abs(next - prev) > 0.005 { - columnPeaks[c] = next - changed = true - } - } - // Steady ~1.6Hz breathe phase for the selected cell, independent - // of audio so the chosen voice keeps gently pulsing during silence. - blinkPhase += 1.0 / 60.0 * 1.6 - if blinkPhase > 1000 { blinkPhase -= 1000 } - if changed || Int(blinkPhase * 15) % 4 == 0 { - needsDisplay = true - } - } - override var intrinsicContentSize: NSSize { NSSize(width: Self.preferredWidth, height: Self.preferredHeight) } @@ -195,24 +92,38 @@ final class InstrumentListView: NSView { // MARK: - Geometry + private static var gridYOffset: CGFloat { midiOutH + midiOutGap } + private func cellRect(program p: Int) -> NSRect { let col = p % Self.cols let row = p / Self.cols return NSRect(x: CGFloat(col) * Self.cellW, - y: CGFloat(row) * Self.cellH, + y: Self.gridYOffset + CGFloat(row) * Self.cellH, width: Self.cellW, height: Self.cellH) } + /// Full-width "0 MIDI OUT" cell at the top of the chooser. Hit-test + /// is exclusive of the patch grid below. + private var midiOutRect: NSRect { + NSRect(x: 0, y: 0, width: bounds.width, height: Self.midiOutH) + } + private func program(at point: NSPoint) -> Int? { guard bounds.contains(point) else { return nil } + let yInGrid = point.y - Self.gridYOffset + guard yInGrid >= 0 else { return nil } let col = Int(point.x / Self.cellW) - let row = Int(point.y / Self.cellH) + let row = Int(yInGrid / Self.cellH) guard col >= 0, col < Self.cols, row >= 0, row < Self.rows else { return nil } let p = row * Self.cols + col return p < 128 ? p : nil } + private func isMidiOutHit(_ point: NSPoint) -> Bool { + midiOutRect.contains(point) + } + /// Hand-picked color per GM family (16 families × 8 programs each; /// each row of the 8-col grid is one family). RGB values mirror /// standard CSS named colors so the timbre→color mapping reads @@ -288,13 +199,41 @@ final class InstrumentListView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) + // MIDI OUT cell (slot 0) — accent-filled when active, outlined + // when inactive. Draws first so the patch grid renders below. + let midiR = midiOutRect + if midiR.intersects(dirtyRect) { + let accent = NSColor.controlAccentColor + let cap = NSBezierPath(roundedRect: midiR.insetBy(dx: 1.75, dy: 1.5), + xRadius: 3, yRadius: 3) + if midiModeActive { + accent.withAlphaComponent(0.85).setFill() + cap.fill() + accent.setStroke() + cap.lineWidth = 1.4 + cap.stroke() + } else { + accent.withAlphaComponent(0.10).setFill() + cap.fill() + accent.withAlphaComponent(0.55).setStroke() + cap.lineWidth = 0.8 + cap.stroke() + } + let labelText = "0 MIDI OUT" + let labelColor: NSColor = midiModeActive ? .white : .labelColor + let labelAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10.5, weight: .semibold), + .foregroundColor: labelColor.withAlphaComponent(midiModeActive ? 1.0 : 0.85), + .kern: 0.4, + ] + let str = NSAttributedString(string: labelText, attributes: labelAttrs) + let size = str.size() + str.draw(at: NSPoint(x: midiR.midX - size.width / 2, + y: midiR.midY - size.height / 2)) + } + let selectedRow = Int(selectedProgram) / Self.cols let selectedCol = Int(selectedProgram) % Self.cols - // Selected cell breathes in alpha at ~1.6 Hz regardless of - // audio; on top of that, the loudest column's level boosts the - // gain so the chosen voice pulses with playing too. - let blinkAmount = 0.5 + 0.5 * CGFloat(sin(blinkPhase * 2 * .pi)) - let amp = CGFloat(columnPeaks.max() ?? 0) for p in 0..<128 { let r = cellRect(program: p) @@ -308,11 +247,9 @@ final class InstrumentListView: NSView { let isSelected = (selectedProgram == UInt8(p)) if isSelected { - // Selected cell: family color pulsing toward white, - // tracking both the steady blink and audio amplitude. - let pulse = min(1.0, 0.55 + blinkAmount * 0.30 + amp * 0.5) - let bg = fam.blended(withFraction: amp * 0.4, of: .white) ?? fam - bg.withAlphaComponent(pulse).setFill() + // Selected cell: solid family color so the chosen + // voice reads as the brightest cell in the grid. + fam.withAlphaComponent(0.85).setFill() NSBezierPath(rect: r).fill() } else { // Family-tinted bed at low opacity so the grid still @@ -361,60 +298,15 @@ final class InstrumentListView: NSView { shadow.shadowBlurRadius = 2 attrs[.shadow] = shadow } - let str = NSAttributedString(string: String(p), attributes: attrs) + // Display 1-based labels (1-128) — internal program index + // stays 0-127 for synth/MIDI compatibility. Slot 0 is the + // virtual "MIDI OUT" address (handled by the toggle below + // the grid); patches occupy 1-128. + let str = NSAttributedString(string: String(p + 1), attributes: attrs) let size = str.size() str.draw(at: NSPoint(x: r.midX - size.width / 2, y: r.midY - size.height / 2)) } - drawColumnBars(in: dirtyRect) - } - - /// Per-column center-out LED bars — each column's smoothed peak - /// maps to a half-height of lit cells, blooming above and below - /// the grid's midline. Lit cells glow in a saturated derivative - /// of their family color so the meter reads as the family palette - /// firing up. The selected cell is skipped (the main draw loop - /// already paints it with a brighter pulse). - private func drawColumnBars(in dirtyRect: NSRect) { - let halfRows = Self.rows / 2 - for c in 0.. 0 else { continue } - for offset in 0..= 0, row < Self.rows else { continue } - let p = row * Self.cols + c - guard p >= 0, p < 128 else { continue } - if UInt8(p) == selectedProgram { continue } - let r = cellRect(program: p) - guard r.intersects(dirtyRect) else { continue } - saturatedGlow(for: p, alpha: intensity).setFill() - NSBezierPath(rect: r.insetBy(dx: 1.75, dy: 1.5)).fill() - } - } - } - } - - /// Take the cell's family color and crank saturation + brightness so - /// the lit overlay reads as a glowing version of the cell's own hue. - /// Returns nil-safe via fallback to the base family color if HSB - /// conversion fails (shouldn't happen for sRGB-defined palette - /// entries, but guards against future palette changes). - private func saturatedGlow(for program: Int, alpha: CGFloat) -> NSColor { - let base = Self.colorForProgram(program) - guard let hsb = base.usingColorSpace(.sRGB) else { - return base.withAlphaComponent(alpha) - } - var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - hsb.getHue(&h, saturation: &s, brightness: &b, alpha: &a) - // Push saturation toward 1, brightness toward 1 — same hue, much - // hotter rendering. Half-step toward max to keep colors that are - // already vivid (magenta, gold) from clipping into nonsense. - let s2 = s + (1 - s) * 0.85 - let b2 = b + (1 - b) * 0.55 - return NSColor(hue: h, saturation: s2, brightness: b2, alpha: alpha) } // MARK: - Mouse @@ -454,11 +346,18 @@ final class InstrumentListView: NSView { } override func mouseDown(with event: NSEvent) { - dragging = true // Take key focus on click so arrow-key navigation works // immediately after the user picks an initial cell. window?.makeFirstResponder(self) let pt = convert(event.locationInWindow, from: nil) + // MIDI OUT cell — slot 0. Click toggles MIDI passthrough mode + // via the controller. Bypasses the drag/preview path because + // there's no audible preview to start. + if isMidiOutHit(pt) { + onMidiOutCommit?() + return + } + dragging = true if let p = program(at: pt) { // Treat the press as a hover-into-this-cell so the preview note // and lit highlight start immediately on click. @@ -510,6 +409,25 @@ final class InstrumentListView: NSView { let cur = Int(selectedProgram) var next = cur var dir = -1 + // Digit keys address slots directly: '0' picks MIDI OUT, '1'-'9' + // pick programs 0-8 (display 1-9). Auto-repeat is suppressed so + // a held digit doesn't re-toggle MIDI mode every tick. Multi- + // digit entry for patches 10-128 isn't wired yet — single-digit + // covers the common "0/1 quick toggle" case the user described. + if !event.isARepeat, + !event.modifierFlags.contains(.shift), + let ch = event.charactersIgnoringModifiers, ch.count == 1, + let digit = Int(ch), (0...9).contains(digit) { + if digit == 0 { + onMidiOutCommit?() + } else { + // onCommit's existing path turns MIDI off (if on) before + // setting the program — same path the chooser click + // uses, so the keyboard "1" matches "click slot 1". + onCommit?(digit - 1) + } + return + } switch event.keyCode { case 123: next = cur - 1; dir = 0 // ← case 124: next = cur + 1; dir = 1 // → diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index 83ce86cb0..0138b3b8b 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -17,6 +17,21 @@ enum KeyboardIconRenderer { case tightActiveRange } + /// ROYGBIV note colors for the natural notes (C → red ... B → violet), + /// keyed by MIDI pitch class. Mirrors `getNoteColorForOctave` in + /// system/public/aesthetic.computer/lib/note-colors.mjs so the menu + /// band's chromatic stripe reads the same as notepat's mini-piano. + /// Sharps/flats return nil — the stripe only paints under naturals. + private static let chromaticColorByPitchClass: [Int: NSColor] = [ + 0: NSColor(srgbRed: 255/255, green: 50/255, blue: 50/255, alpha: 1), // C + 2: NSColor(srgbRed: 255/255, green: 160/255, blue: 0/255, alpha: 1), // D + 4: NSColor(srgbRed: 255/255, green: 230/255, blue: 0/255, alpha: 1), // E + 5: NSColor(srgbRed: 50/255, green: 200/255, blue: 50/255, alpha: 1), // F + 7: NSColor(srgbRed: 50/255, green: 120/255, blue: 255/255, alpha: 1), // G + 9: NSColor(srgbRed: 130/255, green: 50/255, blue: 200/255, alpha: 1), // A + 11: NSColor(srgbRed: 180/255, green: 80/255, blue: 255/255, alpha: 1), // B + ] + /// Updated by AppDelegate.updateIcon() before each render so the renderer /// can pick the right letter labels and active-range without threading /// the keymap through every static method's signature. @@ -331,49 +346,50 @@ enum KeyboardIconRenderer { } // Piano. NSGraphicsContext.saveGraphicsState() - // Clip the leftmost ~1.5pt of the canvas before drawing - // piano keys: the leftmost white key's stroke (lineWidth - // 0.7, plus 2.5pt rounded-corner radius at the tl/bl - // corners) renders as a visible vertical line + curve at - // the icon's far-left edge. Earlier the clip was at x≥0.6 - // — wide enough to swallow the stroke's left half, but the - // corner curves still leaked. Pushing the clip to x≥1.5 - // hides both. The leftmost key's body still draws (the - // clip only swallows about 0.5pt of fill area, indistinct - // visually). - NSBezierPath(rect: NSRect(x: 1.5, - y: 0, - width: imageSize.width, - height: imageSize.height)).addClip() - // Dark-mode awareness: in light mode the piano reads as - // a real piano (white keys white, black keys dark - // accent). In dark mode we swap the relationship — white - // keys go a soft macOS dark-gray, black keys flip to a - // brighter accent so they still pop above the white - // keys. Lit (active) state always rides the accent - // palette so a pressed key contrasts both modes. + // Piano theme: notepat's cool off-white naturals in light + // mode (RGB 215,225,230 → 195,205,210), dropped to a deep + // slate in dark mode so the keys feel native against a + // dark menubar instead of glowing white. Lit (active) + // state always rides the accent palette so a pressed key + // contrasts both backgrounds. let isDark = NSApp.effectiveAppearance.bestMatch( from: [.aqua, .darkAqua]) == .darkAqua - let lit = NSColor.controlAccentColor.highlight(withLevel: 0.30) - ?? NSColor.controlAccentColor - let groove = NSColor.black.withAlphaComponent(isDark ? 0.85 : 0.55) + // Active fill — light mode pops to a brighter accent + // highlight; dark mode dampens slightly toward black so + // the press feedback doesn't blast out of the slate + // keyboard. + let lit: NSColor = isDark + ? (NSColor.controlAccentColor.blended(withFraction: 0.18, of: .black) + ?? NSColor.controlAccentColor) + : (NSColor.controlAccentColor.highlight(withLevel: 0.30) + ?? NSColor.controlAccentColor) + let groove: NSColor let whiteHi: NSColor let whiteLo: NSColor let blackHi: NSColor let blackLo: NSColor if isDark { - // Soft macOS dark-gray for the "white" keys. - whiteHi = NSColor(white: 0.20, alpha: 1.0) - whiteLo = NSColor(white: 0.13, alpha: 1.0) - // Brighter accent for the "black" keys so they - // stand out above the dark grays. - blackHi = NSColor.controlAccentColor.highlight(withLevel: 0.10) - ?? NSColor.controlAccentColor - blackLo = NSColor.controlAccentColor.highlight(withLevel: 0.30) - ?? NSColor.controlAccentColor + groove = NSColor(srgbRed: 140/255, green: 155/255, + blue: 165/255, alpha: 0.55) + whiteHi = NSColor(srgbRed: 62/255, green: 72/255, + blue: 82/255, alpha: 1) + whiteLo = NSColor(srgbRed: 44/255, green: 54/255, + blue: 62/255, alpha: 1) + // Glowy sharps in dark mode — pump saturation + + // brightness so the black keys feel like lit + // accent gems above the slate naturals instead of + // muddy shadows. + blackHi = Self.boostedAccent(saturationBoost: 0.55, + brightnessBoost: 0.45) + blackLo = Self.boostedAccent(saturationBoost: 0.30, + brightnessBoost: 0.20) } else { - whiteHi = NSColor.white - whiteLo = NSColor(white: 0.88, alpha: 1.0) + groove = NSColor(srgbRed: 50/255, green: 65/255, + blue: 75/255, alpha: 0.75) + whiteHi = NSColor(srgbRed: 215/255, green: 225/255, + blue: 230/255, alpha: 1) + whiteLo = NSColor(srgbRed: 195/255, green: 205/255, + blue: 210/255, alpha: 1) blackHi = NSColor.controlAccentColor.shadow(withLevel: 0.30) ?? NSColor.controlAccentColor blackLo = NSColor.controlAccentColor.shadow(withLevel: 0.55) @@ -403,6 +419,10 @@ enum KeyboardIconRenderer { bl: isLeftmost ? 2.5 : 0 ) if isLit { + // Pressed: whole keycap turns the system accent + // — the rainbow stripe stays hidden while the + // key's down so the press reads as a single + // saturated event, not a stripe-grow animation. lit.setFill() path.fill() } else { @@ -412,6 +432,72 @@ enum KeyboardIconRenderer { NSColor.controlAccentColor.withAlphaComponent(0.50).setFill() path.fill() } + // Chromatic stripe — thin flat ROYGBIV band along + // the bottom of each natural key, idle only. Hidden + // on press so the lit accent fill reads cleanly. + // Dark mode dims the chroma toward black so it + // doesn't read as neon against dark slate keys. + let stripeH: CGFloat = keyHeightScale > 1.0 ? 3.0 : 2.0 + if let chroma = Self.chromaticColorByPitchClass[m % 12], !isLit { + let stripeChroma: NSColor = isDark + ? (chroma.blended(withFraction: 0.18, of: .black) ?? chroma) + : chroma + NSGraphicsContext.saveGraphicsState() + path.addClip() + let stripeRect = NSRect( + x: rect.minX, + y: rect.minY, + width: rect.width, + height: stripeH + ) + if isDark { + // Backlit-organ glow — clipped to the lower + // portion of the keycap so the halo radiates + // sideways + downward without bleeding up + // into the key's top edge. + NSGraphicsContext.saveGraphicsState() + let glowClipH = stripeH + 4 + let glowBox = NSRect( + x: rect.minX - 8, + y: rect.minY - 8, + width: rect.width + 16, + height: glowClipH + 8 + ) + NSBezierPath(rect: glowBox).addClip() + Self.withGlow(color: chroma, blur: 4.5, alpha: 0.85) { + stripeChroma.setFill() + NSBezierPath(rect: stripeRect).fill() + } + NSGraphicsContext.restoreGraphicsState() + } else { + stripeChroma.setFill() + NSBezierPath(rect: stripeRect).fill() + } + // Top-edge faux lighting — light mode catches a + // soft white sheen from above (ambient lamp); + // dark mode flips to a thin dark vignette so + // the keycap top reads as a pulled-down crown + // rather than a glowy halo, which would fight + // with the chromatic glow at the bottom. + let topH: CGFloat = min(5, rect.height * 0.30) + let topRect = NSRect( + x: rect.minX, + y: rect.maxY - topH, + width: rect.width, + height: topH + ) + let topGradient: NSGradient? = isDark + ? NSGradient( + starting: NSColor.black.withAlphaComponent(0.30), + ending: NSColor.black.withAlphaComponent(0) + ) + : NSGradient( + starting: NSColor.white.withAlphaComponent(0.55), + ending: NSColor.white.withAlphaComponent(0) + ) + topGradient?.draw(in: topRect, angle: -90) + NSGraphicsContext.restoreGraphicsState() + } groove.setStroke() path.lineWidth = 0.7 path.stroke() @@ -433,11 +519,16 @@ enum KeyboardIconRenderer { a = typeMode ? 1.0 : 0.0 } if a > 0.01 { - drawWhiteLabel(display, in: rect, lit: isLit, alpha: a) + // Labels stay anchored at the bottom of each + // key — the chromatic stripe paints behind + // them, so the letter reads on the colored + // band rather than floating above it. + drawWhiteLabel(display, in: rect, lit: isLit, alpha: a, + chroma: Self.chromaticColorByPitchClass[m % 12]) } } } - for m in firstMidi...lastMidi where !isWhite(m) { + for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) { if !isActive(m) { continue } // negative space var leftWhite = m - 1 while !isWhite(leftWhite) { leftWhite -= 1 } @@ -447,8 +538,29 @@ enum KeyboardIconRenderer { let isHover = hovered == .note(UInt8(m)) let path = roundedKeyPath(rect: rect, tl: 0, tr: 0, br: 1.2, bl: 1.2) if isLit { - lit.setFill() - path.fill() + if isDark { + // Invert in dark mode — the saturated bright + // sharp flips to a deep slate notch on press + // so the key feels recessed into the keybed + // instead of getting brighter on top of an + // already-glowing surface. + NSColor(srgbRed: 22/255, green: 30/255, + blue: 36/255, alpha: 1).setFill() + path.fill() + } else { + lit.setFill() + path.fill() + } + } else if isDark { + // Sharps glow with the system color in dark + // mode — same backlit-organ feel as the + // chromatic stripe under the naturals. + Self.withGlow(color: NSColor.controlAccentColor, + blur: 3.5, + alpha: 0.55) { + NSGradient(starting: blackHi, ending: blackLo)! + .draw(in: path, angle: -90) + } } else { NSGradient(starting: blackHi, ending: blackLo)!.draw(in: path, angle: -90) } @@ -550,7 +662,7 @@ enum KeyboardIconRenderer { // the user sees on screen — clicking on visible black triggers black, // clicking visible white triggers white. Inactive (negative-space) // keys are non-interactive. - for m in firstMidi...lastMidi where !isWhite(m) { + for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) { if !isActive(m) { continue } var leftWhite = m - 1 while !isWhite(leftWhite) { leftWhite -= 1 } @@ -613,7 +725,7 @@ enum KeyboardIconRenderer { if point.x >= leftEdge && point.x < rightEdge && point.y >= blackYMin { var whiteIndex: [Int: Int] = [:] for (i, m) in whites.enumerated() { whiteIndex[m] = i } - for m in firstMidi...lastMidi where !isWhite(m) { + for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) { if !isActive(m) { continue } var leftWhite = m - 1 while !isWhite(leftWhite) { leftWhite -= 1 } @@ -701,40 +813,91 @@ enum KeyboardIconRenderer { return path } + // MARK: - Color helpers + + /// Pump HSB saturation + brightness on the system accent so the + /// dark-mode sharps glow with a saturated version of the user's + /// system color instead of a muddy shadow. + private static func boostedAccent(saturationBoost: CGFloat, + brightnessBoost: CGFloat) -> NSColor { + let base = NSColor.controlAccentColor.usingColorSpace(.sRGB) + ?? NSColor.controlAccentColor + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + base.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + let s2 = min(1, s + (1 - s) * saturationBoost) + let b2 = min(1, b + (1 - b) * brightnessBoost) + return NSColor(hue: h, saturation: s2, brightness: b2, alpha: a) + } + + /// Apply a soft NSShadow glow inside `body` — same hue radiating + /// outward, no offset, decent blur. Reads like a backlit organ + /// key with light leaking around its edges. The shadow state is + /// scoped to one save/restore so it never leaks to later draws. + private static func withGlow(color: NSColor, + blur: CGFloat, + alpha: CGFloat, + _ body: () -> Void) { + NSGraphicsContext.saveGraphicsState() + let glow = NSShadow() + glow.shadowColor = color.withAlphaComponent(alpha) + glow.shadowBlurRadius = blur + glow.shadowOffset = .zero + glow.set() + body() + NSGraphicsContext.restoreGraphicsState() + } + // MARK: - Key labels - private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { + private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0, bottomOffset: CGFloat = 0, chroma: NSColor? = nil) { guard alpha > 0.01 else { return } - // Lit cells always wear pure-white labels (over the bright - // accent fill). Unlit cells need to flip with the - // appearance: dark-text in light mode (over a near-white - // keycap) becomes light-text in dark mode (over a dark-gray - // keycap). + // Lit keys fill with the system accent — label flips to a + // dark on-color shade so the letter reads as ink stamped on + // the colored keycap rather than glowing white. Idle keys + // adapt to system theme: near-black on light off-white, + // near-white on dark slate. (chroma is unused now but kept + // for any future per-note label tinting.) + _ = chroma let isDark = NSApp.effectiveAppearance.bestMatch( from: [.aqua, .darkAqua]) == .darkAqua - let unlitBase = isDark - ? NSColor(white: 0.85, alpha: 1.0) + let unlit: NSColor = isDark + ? NSColor(white: 0.92, alpha: 1.0) : NSColor(white: 0.28, alpha: 1.0) - let base: NSColor = lit ? .white : unlitBase + let base: NSColor = lit ? NSColor(white: 0.12, alpha: 1.0) : unlit let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 9.0, weight: .heavy), .foregroundColor: base.withAlphaComponent(alpha), ] let str = NSAttributedString(string: text, attributes: attrs) let size = str.size() - // White key labels sit a couple pixels off the bottom — high + // White key labels sit a few pixels off the bottom — high // enough that the descender on `j` doesn't kiss the menubar - // edge, low enough that the letters feel anchored in the - // bottom of the key rather than floating mid-cell. + // edge and that the letter floats clearly above the + // chromatic stripe at the keycap's foot. Caps drop ~1pt + // lower so the taller uppercase glyphs don't bump into the + // black-key label band above. + let baseY: CGFloat = labelsUppercase ? 2.0 : 3.0 str.draw(at: NSPoint(x: rect.midX - size.width / 2, - y: rect.minY + 1.8)) + y: rect.minY + baseY + bottomOffset)) } private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { guard alpha > 0.01 else { return } + // Sharp body brightness depends on the *XOR* of lit + dark + // — dark-mode unlit sharps glow saturated accent, dark-mode + // lit sharps invert to deep slate; light-mode is the + // opposite (unlit dark accent, lit bright accent). Pick the + // label color from whichever surface the letter actually + // lands on. + let isDark = NSApp.effectiveAppearance.bestMatch( + from: [.aqua, .darkAqua]) == .darkAqua + let onBrightFill = lit != isDark + let foreground: NSColor = onBrightFill + ? NSColor(white: 0.10, alpha: 1.0) + : NSColor.white let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 8.0, weight: .heavy), - .foregroundColor: NSColor.white.withAlphaComponent(0.96 * alpha), + .foregroundColor: foreground.withAlphaComponent(0.96 * alpha), ] let str = NSAttributedString(string: text, attributes: attrs) let size = str.size() @@ -922,6 +1085,15 @@ enum KeyboardIconRenderer { NSColor.black.set() miniVisualizerPunchRect.fill() ctx.restoreGraphicsState() + // The destination-out punch clears the hover backdrop in + // the bars area too — leaving an oddly dark hole behind + // the bars when the chip is hovered/clicked. Repaint the + // same hover-backdrop color into the punched zone so the + // bars sit on a uniform pill instead of a cut-out shadow. + if hovered { + NSColor.labelColor.withAlphaComponent(0.12).setFill() + miniVisualizerPunchRect.fill() + } drawChipVisualizer(in: miniVisualizerRect, level: visualizerLevel, hovered: visualizerHovered, color: color, baseAlpha: alpha) @@ -1002,7 +1174,10 @@ enum KeyboardIconRenderer { drawHoverBackdrop(in: hoverRect, hovered: hovered) let safeIdx = max(0, min(127, Int(program))) let abbrev = GeneralMIDI.familyAbbrev(for: program) - let label = String(format: "%@ %03d", abbrev, safeIdx) + // Display 1-based GM index (1-128). Slot 0 is reserved as + // "MIDI OUT" — the menubar shows that label separately when + // MIDI passthrough is active. + let label = String(format: "%@ %03d", abbrev, safeIdx + 1) let alpha: CGFloat = hovered ? 1.0 : 0.82 let attrs: [NSAttributedString.Key: Any] = [ .font: processingFont(size: 10.0), diff --git a/slab/menuband/Sources/MenuBand/Localization.swift b/slab/menuband/Sources/MenuBand/Localization.swift index aae93078f..5c90883e8 100644 --- a/slab/menuband/Sources/MenuBand/Localization.swift +++ b/slab/menuband/Sources/MenuBand/Localization.swift @@ -83,7 +83,6 @@ enum Localization { "popover.octave.down": "Octave down", "popover.octave.up": "Octave up", "popover.midi.label": "MIDI", - "popover.update.button": "Open menuband.com", "popover.update.available": "Update available: %@", // Popover — layout block @@ -112,7 +111,8 @@ enum Localization { // Popover — about / footer "popover.about.lead": "Menu Band", - "popover.about.body": " brings the built-in macOS instruments into the menu bar.", + "popover.about.body": " makes the built-in macOS MIDI instruments playable right from the menu bar.", + "popover.about.link": "About", "popover.about.quit": "Quit Menu Band", "popover.about.crash.send": "Send crash reports", "popover.about.crash.sending": "Sending…", @@ -140,7 +140,6 @@ enum Localization { "popover.octave.down": "Bajar octava", "popover.octave.up": "Subir octava", "popover.midi.label": "MIDI", - "popover.update.button": "Abrir menuband.com", "popover.update.available": "Actualización disponible: %@", // Popover — layout block @@ -170,7 +169,8 @@ enum Localization { // Popover — about / footer "popover.about.lead": "Menu Band", "popover.about.body": - " trae los instrumentos integrados de macOS a la barra de menús.", + " hace tocables los instrumentos MIDI integrados de macOS directamente desde la barra de menús.", + "popover.about.link": "Acerca de", "popover.about.quit": "Salir de Menu Band", "popover.about.crash.send": "Enviar informes de fallos", "popover.about.crash.sending": "Enviando…", diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 4479ffce9..a01ac6049 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -1088,21 +1088,28 @@ final class MenuBandController { return true } - // Number-row digits 0–9 build up a GM program selection (0–127). - // Each digit appends to the buffer and applies the new value live; - // a 3-digit cap means the 4th press starts over with that digit - // alone, so the user can sweep voices without a clear key. Down- - // events only — repeats are consumed silently. Always consume so - // digit keystrokes never leak through to the focused app. + // Number-row digits 0–9 select a voice using the chooser + // grid's 1-based numbering: 0 / 00 / 000 is the MIDI + // passthrough slot, "1" picks GM program 0 (Acoustic Grand, + // displayed as voice 1), …, "128" picks program 127. Picking + // a non-zero voice forces the backend back to internal-synth + // playback so the user can sweep out of MIDI mode by typing. + // 3-digit cap means the 4th press starts a fresh sequence. + // Down-events only. if let digit = Self.digitForKeyCode(keyCode) { if isDown && !isRepeat { if voiceDigitBuffer.count >= 3 { voiceDigitBuffer = "" } voiceDigitBuffer.append(String(digit)) - if let v = Int(voiceDigitBuffer) { - let program = UInt8(max(0, min(127, v))) - DispatchQueue.main.async { [weak self] in - self?.setMelodicProgram(program) + let buffer = voiceDigitBuffer + DispatchQueue.main.async { [weak self] in + guard let self = self, let v = Int(buffer) else { return } + if v == 0 { + if !self.midiMode { self.toggleMIDIMode() } + return } + if self.midiMode { self.toggleMIDIMode() } + let program = UInt8(max(0, min(127, v - 1))) + self.setMelodicProgram(program) } } return true diff --git a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift index ada40f2c1..08b9f829e 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift @@ -248,9 +248,15 @@ final class MenuBandMIDI { // user has to re-enable Track in Live's MIDI prefs to hear // notes. Pinning UID + manufacturer + model means Ableton's // routing survives reinstalls. - // UID is a 32-bit signed int; 0x4D424E44 = ASCII "MBND". + // UID is a 32-bit signed int. Originally 0x4D424E44 ("MBND") + // for stability across reinstalls. Bumped once after Ableton + // Live 12.3.8's cached entry for the original UID went stale + // (Track On wouldn't stick / audio dropped) — forcing a new + // UID makes Live treat it as a fresh device and write a + // clean MidiInDevicePreferences entry. Bump again the next + // time a DAW's per-port cache gets wedged. MIDIObjectSetIntegerProperty(source, kMIDIPropertyUniqueID, - Int32(bitPattern: 0x4D424E44)) + Int32(bitPattern: 0x4D424E45)) MIDIObjectSetStringProperty(source, kMIDIPropertyManufacturer, "aesthetic.computer" as CFString) MIDIObjectSetStringProperty(source, kMIDIPropertyModel, diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index 1253d6bf2..6e914ce65 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -106,7 +106,6 @@ final class MenuBandPopoverViewController: NSViewController { /// Owning popover, set by AppDelegate after construction. Held weak so /// we don't extend its lifetime; used to animate `contentSize` when the /// instrument palette collapses / expands. - weak var popover: NSPopover? var onFocusShortcutChange: ((MenuBandShortcut) -> Bool)? var onFocusShortcutRecordingChanged: ((Bool) -> Void)? var onPlayPaletteToggle: (() -> Void)? @@ -158,8 +157,15 @@ final class MenuBandPopoverViewController: NSViewController { private var crashStatusLabel: NSTextField! private var crashHintLabel: NSTextField! private var crashSendButton: NSButton! - private var updateBanner: NSView! - private var updateLabel: NSTextField! + /// Cached result of the most recent UpdateChecker fetch. Populated + /// asynchronously after view load; surfaced inside the custom About + /// window when the user opens it. + private var latestRemoteVersion: UpdateChecker.VersionInfo? + + /// Retained so the floating About window stays alive after + /// `showAboutPanel` returns. Recreated on each open so the update + /// state reflects the latest manifest fetch. + private var aboutWindowController: AboutWindowController? /// Layered substrate for the held-notes pills + chord cards. The /// MTL waveform that used to live inside this bezel has been /// retired; the housing stays for visual continuity (rounded @@ -306,58 +312,21 @@ final class MenuBandPopoverViewController: NSViewController { titleRow.addArrangedSubview(metronome) titleRow.setCustomSpacing(8, after: metronome) - // MIDI toggle — tucked into the title row instead of its own panel. - // Enabling MIDI also silences the local keyboard (notes route to the - // DAW instead), so a separate mute button would be redundant. + // MIDI toggle is now slot 0 in the chooser ("0 MIDI OUT"). The + // ivars below stay so existing references (status sync, the + // legacy controller-on-change handler) keep compiling without + // touching every callsite — they're driven invisibly. midiSwitch = NSSwitch() midiSwitch.target = self midiSwitch.action = #selector(midiSwitchToggled(_:)) - midiInlineLabel = NSTextField(labelWithString: L("popover.midi.label")) - midiInlineLabel.font = NSFont.systemFont(ofSize: 10, weight: .semibold) - midiInlineLabel.textColor = .secondaryLabelColor - titleRow.addArrangedSubview(midiInlineLabel) - titleRow.setCustomSpacing(4, after: midiInlineLabel) - titleRow.addArrangedSubview(midiSwitch) + midiSwitch.isHidden = true + midiInlineLabel = NSTextField(labelWithString: "") + midiInlineLabel.isHidden = true stack.addArrangedSubview(titleRow) titleRow.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -16).isActive = true - // Update banner — hidden until UpdateChecker reports a newer - // release. Tinted accent so the user notices it without it feeling - // like an alert. - updateBanner = NSView() - updateBanner.wantsLayer = true - updateBanner.layer?.backgroundColor = NSColor.controlAccentColor - .withAlphaComponent(0.14).cgColor - updateBanner.layer?.cornerRadius = 6 - updateBanner.translatesAutoresizingMaskIntoConstraints = false - updateLabel = NSTextField(labelWithString: "") - updateLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) - updateLabel.textColor = .labelColor - updateLabel.lineBreakMode = .byWordWrapping - updateLabel.maximumNumberOfLines = 0 - updateLabel.translatesAutoresizingMaskIntoConstraints = false - let updateLink = NSButton(title: L("popover.update.button"), - target: self, - action: #selector(openMenuBandSite)) - updateLink.bezelStyle = .recessed - updateLink.controlSize = .small - updateLink.translatesAutoresizingMaskIntoConstraints = false - updateBanner.addSubview(updateLabel) - updateBanner.addSubview(updateLink) - NSLayoutConstraint.activate([ - updateLabel.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), - updateLabel.topAnchor.constraint(equalTo: updateBanner.topAnchor, constant: 7), - updateLabel.trailingAnchor.constraint(equalTo: updateBanner.trailingAnchor, constant: -10), - updateLink.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), - updateLink.topAnchor.constraint(equalTo: updateLabel.bottomAnchor, constant: 4), - updateLink.bottomAnchor.constraint(equalTo: updateBanner.bottomAnchor, constant: -7), - ]) - stack.addArrangedSubview(updateBanner) - updateBanner.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true - updateBanner.isHidden = true - stack.addArrangedSubview(makeSeparator()) // Input mode picker. Three states: @@ -811,57 +780,10 @@ final class MenuBandPopoverViewController: NSViewController { stack.setCustomSpacing(14, after: waveformBezel) - // About + Crash logs in a side-by-side row. About has low hugging - // so it expands when the crash column is hidden (no reports) — - // takes the whole row instead of leaving negative space on the - // right. With reports present, the crash column claims its - // intrinsic content width and About fills what's left. - let aboutCrashRow = NSStackView() - aboutCrashRow.orientation = .horizontal - aboutCrashRow.alignment = .top - aboutCrashRow.distribution = .fill - aboutCrashRow.spacing = 12 - - let aboutCol = NSStackView() - aboutCol.orientation = .vertical - aboutCol.alignment = .leading - aboutCol.spacing = 6 - // No heading — the prose itself is the about content. The bold - // "Menu Band" header on top read as a duplicate of the menubar - // identity above and ate vertical space. - let aboutBody = NSTextField(wrappingLabelWithString: "") - aboutBody.font = NSFont.systemFont(ofSize: 10.5) - aboutBody.textColor = .secondaryLabelColor - aboutBody.maximumNumberOfLines = 0 - aboutBody.lineBreakMode = .byWordWrapping - // "Menu Band" stays bold + label-colored; the rest of the - // sentence is regular weight in secondary color so the eye - // catches the brand first. - let aboutText = NSMutableAttributedString() - let bodyFont = NSFont.systemFont(ofSize: 10.5) - let boldFont = NSFont.systemFont(ofSize: 10.5, weight: .bold) - aboutText.append(NSAttributedString(string: L("popover.about.lead"), - attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor])) - aboutText.append(NSAttributedString( - string: L("popover.about.body"), - attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor])) - aboutBody.attributedStringValue = aboutText - aboutBody.preferredMaxLayoutWidth = InstrumentListView.preferredWidth - aboutCol.setContentHuggingPriority(.defaultLow, for: .horizontal) - aboutCol.addArrangedSubview(aboutBody) - // Aesthetic.Computer brand badge — purple-on-pale-purple chip. - let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) - let acLink = Self.makeLinkButton( - attr: Self.aestheticComputerTitle(), - target: self, action: #selector(openAesthetic), - background: acPurple.withAlphaComponent(0.14), - border: acPurple.withAlphaComponent(0.55)) - aboutCol.addArrangedSubview(acLink) - - // Crash-send moved out of this row — it now lives next to Quit - // below as a small standalone button. Keeping it here as a side-by- - // side column was pushing the about copy and clipping the popover - // bottom on multi-line crash hints. + // Description + brand chip moved out of the popover proper — + // they now live in the standard macOS About panel reachable via + // the small "About" link at bottom-left. Frees the popover to + // be operational chrome. crashStatusLabel = NSTextField(labelWithString: "") // legacy ivar — unused crashHintLabel = NSTextField(labelWithString: "") // legacy ivar — unused crashSendButton = NSButton(title: L("popover.about.crash.send"), @@ -871,12 +793,6 @@ final class MenuBandPopoverViewController: NSViewController { crashSendButton.controlSize = .small crashSendButton.isHidden = true // shown by refreshCrashStatus when n>0 - aboutCrashRow.addArrangedSubview(aboutCol) - stack.addArrangedSubview(aboutCrashRow) - // Air between the About/Crash block and the Quit button below so - // Quit reads as its own action, not a list item under About. - stack.setCustomSpacing(10, after: aboutCrashRow) - // Language switcher — compact flag-chip row, same pattern as the // kidlisp.com / help.aesthetic.computer pickers. The active language // is solid; the others are flat. Tapping a chip flips the locale and @@ -944,12 +860,31 @@ final class MenuBandPopoverViewController: NSViewController { .font: NSFont.systemFont(ofSize: 11, weight: .semibold), ] ) + // Small "About" link, bottom-left. Opens the standard macOS + // about panel — name, icon, version, credits (description + + // aesthetic.computer link). Replaces the inline AC chip that + // used to live in the body. + let aboutLink = NSButton() + aboutLink.bezelStyle = .recessed + aboutLink.isBordered = false + aboutLink.controlSize = .small + aboutLink.attributedTitle = NSAttributedString( + string: L("popover.about.link"), + attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.systemFont(ofSize: 10, weight: .medium), + ] + ) + aboutLink.target = self + aboutLink.action = #selector(showAboutPanel(_:)) + let quitRow = NSStackView() quitRow.orientation = .horizontal quitRow.alignment = .centerY quitRow.spacing = 8 let quitSpacer = NSView() quitSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + quitRow.addArrangedSubview(aboutLink) quitRow.addArrangedSubview(crashSendButton) quitRow.addArrangedSubview(quitSpacer) quitRow.addArrangedSubview(quit) @@ -1172,7 +1107,7 @@ final class MenuBandPopoverViewController: NSViewController { } updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) refreshCrashStatus() - refreshUpdateBanner() + refreshUpdateInfo() // Instrument palette: stays in the layout but greys out when // MIDI mode owns the audio path. Same physical width either way. applyInstrumentPaletteVisibility(midiMode: n.midiMode) @@ -1186,17 +1121,16 @@ final class MenuBandPopoverViewController: NSViewController { } } // Re-fit the popover after sync. preferredContentSize was locked - // in loadView() while the crash column was empty/hidden and the - // update banner was not yet shown; both can grow the layout - // (multi-line crash hint, banner row) and would otherwise be - // clipped at the bottom of the popover. + // in loadView() while the crash column was empty/hidden; a + // multi-line crash hint can grow the layout and would otherwise + // be clipped at the bottom of the popover. refitContentSize() } /// Re-measure the stack's intrinsic fitting size and update /// `preferredContentSize` to match. Run after any change that can /// add/remove rows or change wrapping height (crash status, - /// update banner, instrument palette toggle). + /// instrument palette toggle). private func refitContentSize() { guard isViewLoaded else { return } view.needsLayout = true @@ -1338,7 +1272,13 @@ final class MenuBandPopoverViewController: NSViewController { let safe = max(0, min(127, Int(m.melodicProgram))) let title: String let famColor: NSColor - if m.instrumentBackend == .kpbj { + if m.midiMode { + // MIDI mode = "instrument 0" in the addressable system. + // Title reads simply "MIDI" — short enough to fit and + // makes the routing instantly legible. + title = "MIDI" + famColor = NSColor.controlAccentColor + } else if m.instrumentBackend == .kpbj { // Voice −1: live KPBJ stream replaces the GM grid. Distinct // amber lets the user spot it immediately and fits the // KPBJ web piece's sunrise palette. @@ -1430,13 +1370,6 @@ final class MenuBandPopoverViewController: NSViewController { fileprivate func handleEffectiveAppearanceChange() { rootBackgroundView?.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - // Update banner uses controlAccentColor.cgColor at build time — - // accent doesn't normally re-tone with light/dark, but the - // semi-transparent fill reads visibly different over a flipped - // window background, so re-resolve it against the current - // appearance to keep the cached cgColor honest. - updateBanner?.layer?.backgroundColor = NSColor.controlAccentColor - .withAlphaComponent(0.14).cgColor applyAppearanceToVisualizer() refreshHeldNotes() updateInstrumentReadout() @@ -1642,25 +1575,16 @@ final class MenuBandPopoverViewController: NSViewController { } /// Hit the manifest at assets.aesthetic.computer/menuband/latest.json - /// and show the banner if there's a newer version available than the - /// one running. Cached for an hour inside UpdateChecker. - private func refreshUpdateBanner() { - let current = UpdateChecker.currentVersion() + /// and stash the result for the About panel to surface. Cached for + /// an hour inside UpdateChecker. + private func refreshUpdateInfo() { UpdateChecker.fetchLatest { [weak self] info in - guard let self = self, let info = info else { return } - if UpdateChecker.isNewer(info.version, than: current) { - let notes = info.notes?.isEmpty == false ? " — \(info.notes!)" : "" - self.updateLabel.stringValue = - L("popover.update.available", "\(info.version)\(notes)") - self.updateBanner.isHidden = false - } else { - self.updateBanner.isHidden = true - } + self?.latestRemoteVersion = info } } @objc private func openMenuBandSite() { - if let url = URL(string: "https://aesthetic.computer/menuband") { + if let url = URL(string: "https://prompt.ac/menuband") { NSWorkspace.shared.open(url) } } @@ -1923,6 +1847,24 @@ final class MenuBandPopoverViewController: NSViewController { } } + /// Classic macOS About panel — bundle icon, name, version, plus a + /// credits block carrying the "Menu Band brings the built-in macOS + /// instruments…" line and a clickable aesthetic.computer link. + /// Replaces the inline AC chip that used to live in the popover. + @objc private func showAboutPanel(_ sender: Any?) { + // Kick off a fresh update check; if it lands before the user + // dismisses the window the next open will reflect it. The first + // open after launch shows whatever sync-time call cached. + refreshUpdateInfo() + + // Rebuild every open so the flashing button (and version row) + // pick up the most recent update info instead of going stale. + aboutWindowController?.close() + let ctrl = AboutWindowController(updateInfo: latestRemoteVersion) + aboutWindowController = ctrl + ctrl.present() + } + @objc private func openNotepat() { if let url = URL(string: "https://notepat.com") { NSWorkspace.shared.open(url) diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift new file mode 100644 index 000000000..ac2972d72 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift @@ -0,0 +1,189 @@ +// MenuBandPopoverPanel.swift +// +// Custom popover-styled NSPanel. NSPopover places its arrow at the +// anchor's screen-x and auto-fits the content to the visible screen, +// which means the arrow always lands roughly where the anchor sits +// — when the status item is near the screen edge, the popover content +// shifts inward and the arrow ends up on the inward side of the +// content rather than flush at one corner. This panel decouples the +// two: the window's frame and the arrow tip's screen-x are set +// independently, so the content can sit far from the arrow and the +// arrow can land at any horizontal position on the panel's top edge. +// +// Visually it mimics NSPopover: rounded body + small triangular arrow +// rendered through a single NSVisualEffectView with a CAShapeLayer +// mask, so the liquid-glass material flows continuously from the +// arrow into the body. + +import AppKit + +final class MenuBandPopoverPanel: NSPanel { + static let arrowHeight: CGFloat = 11 + static let arrowWidth: CGFloat = 22 + static let cornerRadius: CGFloat = 10 + + let chrome: MenuBandPopoverChrome + + init(content: NSView, contentSize: NSSize) { + let totalSize = NSSize( + width: contentSize.width, + height: contentSize.height + Self.arrowHeight + ) + chrome = MenuBandPopoverChrome(content: content) + super.init( + contentRect: NSRect(origin: .zero, size: totalSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isOpaque = false + backgroundColor = .clear + hasShadow = true + level = .popUpMenu + animationBehavior = .none + collectionBehavior = [.transient, .ignoresCycle] + hidesOnDeactivate = false + canHide = false + isMovableByWindowBackground = false + acceptsMouseMovedEvents = true + titleVisibility = .hidden + titlebarAppearsTransparent = true + isReleasedWhenClosed = false + contentView = chrome + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// Position the panel so that: + /// • the panel's top edge sits at `topScreenY` (the menubar bottom), + /// • the panel's left edge sits at `leftScreenX`, + /// • the arrow tip points at `arrowScreenX`. + /// `arrowScreenX` may be inside or outside the panel's horizontal + /// extent; the chrome clamps the rendered arrow to a small inset so + /// it never falls off the rounded corner radii. + func position(leftScreenX: CGFloat, topScreenY: CGFloat, arrowScreenX: CGFloat) { + let frameSize = frame.size + let panelFrame = NSRect( + x: leftScreenX, + y: topScreenY - frameSize.height, + width: frameSize.width, + height: frameSize.height + ) + setFrame(panelFrame, display: true) + // Arrow position is in chrome-local coords (origin at panel + // bottom-left). Convert from screen-x. + let arrowLocalX = arrowScreenX - leftScreenX + chrome.setArrowOffsetFromLeft(arrowLocalX) + } +} + +final class MenuBandPopoverChrome: NSView { + private let visualEffect = NSVisualEffectView() + private let content: NSView + private let maskLayer = CAShapeLayer() + private var arrowOffsetFromLeft: CGFloat = MenuBandPopoverPanel.cornerRadius + + MenuBandPopoverPanel.arrowWidth / 2 + + init(content: NSView) { + self.content = content + super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = false + + // The whole panel area (body + arrow) is one continuous + // visual-effect view. A CAShapeLayer mask carves out the + // popover silhouette, so the liquid-glass material flows from + // the arrow tip down into the body without a seam. + visualEffect.material = .popover + visualEffect.blendingMode = .behindWindow + visualEffect.state = .active + visualEffect.wantsLayer = true + visualEffect.translatesAutoresizingMaskIntoConstraints = false + addSubview(visualEffect) + + content.translatesAutoresizingMaskIntoConstraints = false + addSubview(content) + + NSLayoutConstraint.activate([ + visualEffect.leadingAnchor.constraint(equalTo: leadingAnchor), + visualEffect.trailingAnchor.constraint(equalTo: trailingAnchor), + visualEffect.topAnchor.constraint(equalTo: topAnchor), + visualEffect.bottomAnchor.constraint(equalTo: bottomAnchor), + // Content sits in the body region (below the arrow strip). + content.leadingAnchor.constraint(equalTo: leadingAnchor), + content.trailingAnchor.constraint(equalTo: trailingAnchor), + content.topAnchor.constraint( + equalTo: topAnchor, + constant: MenuBandPopoverPanel.arrowHeight), + content.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + visualEffect.layer?.mask = maskLayer + } + + required init?(coder: NSCoder) { fatalError() } + + override var isFlipped: Bool { false } + + func setArrowOffsetFromLeft(_ offset: CGFloat) { + // Clamp so the arrow fits between the rounded body corners. + let minX = MenuBandPopoverPanel.cornerRadius + + MenuBandPopoverPanel.arrowWidth / 2 + 2 + let maxX = bounds.width - MenuBandPopoverPanel.cornerRadius + - MenuBandPopoverPanel.arrowWidth / 2 - 2 + arrowOffsetFromLeft = max(minX, min(maxX, offset)) + rebuildMask() + } + + override func layout() { + super.layout() + rebuildMask() + } + + private func rebuildMask() { + let size = bounds.size + guard size.width > 0, size.height > 0 else { return } + + let arrowH = MenuBandPopoverPanel.arrowHeight + let arrowW = MenuBandPopoverPanel.arrowWidth + let radius = MenuBandPopoverPanel.cornerRadius + + // Body rect: the rounded rectangle below the arrow strip. + // The arrow is drawn as a small triangle attached to the body's + // top edge, so the mask is one continuous path. + let bodyTop = size.height - arrowH + let arrowTipX = max(arrowOffsetFromLeft, radius + arrowW / 2 + 2) + + let path = CGMutablePath() + // Bottom-left → bottom-right with rounded corners + path.move(to: CGPoint(x: 0, y: radius)) + path.addArc(tangent1End: CGPoint(x: 0, y: 0), + tangent2End: CGPoint(x: radius, y: 0), + radius: radius) + path.addLine(to: CGPoint(x: size.width - radius, y: 0)) + path.addArc(tangent1End: CGPoint(x: size.width, y: 0), + tangent2End: CGPoint(x: size.width, y: radius), + radius: radius) + // Right side up to body top + path.addLine(to: CGPoint(x: size.width, y: bodyTop - radius)) + path.addArc(tangent1End: CGPoint(x: size.width, y: bodyTop), + tangent2End: CGPoint(x: size.width - radius, y: bodyTop), + radius: radius) + // Body top edge → arrow base right + path.addLine(to: CGPoint(x: arrowTipX + arrowW / 2, y: bodyTop)) + // Arrow tip + path.addLine(to: CGPoint(x: arrowTipX, y: size.height)) + // Arrow base left + path.addLine(to: CGPoint(x: arrowTipX - arrowW / 2, y: bodyTop)) + // Continue body top edge to top-left corner radius + path.addLine(to: CGPoint(x: radius, y: bodyTop)) + path.addArc(tangent1End: CGPoint(x: 0, y: bodyTop), + tangent2End: CGPoint(x: 0, y: bodyTop - radius), + radius: radius) + path.closeSubpath() + + maskLayer.path = path + maskLayer.frame = bounds + } +} diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index 7e10f67ac..56a2a5b84 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -33,11 +33,6 @@ final class CollapsedPianoWaveformView: NSView { /// instrument," and the popover stays a music-theory surface. private let modeStack = NSStackView() private var modeButtons: [NSButton] = [] - /// Compact "About" row at the panel's bottom — Menu Band - /// description + aesthetic.computer link. Moved out of the - /// popover so the popover stays a music-theory surface. - private let aboutBody = NSTextField(wrappingLabelWithString: "") - private let aboutLinkButton = NSButton() private var trackingArea: NSTrackingArea? private weak var paletteGlassView: NSView? @@ -49,7 +44,6 @@ final class CollapsedPianoWaveformView: NSView { private static let arrowsRowHeight: CGFloat = 34 private static let modeRowHeight: CGFloat = 22 - private static let aboutRowHeight: CGFloat = 36 private static let edgePadding: CGFloat = 6 private static let rowGap: CGFloat = 4 /// Reserved at the top — hosts the chord-candidate row above @@ -77,6 +71,13 @@ final class CollapsedPianoWaveformView: NSView { m.setMelodicProgram(UInt8(prog)) self.refresh() } + // Slot 0 — "MIDI OUT" addressable cell at the top of the + // grid. Toggles MIDI passthrough mode on the controller; the + // refresh() call repaints the cell in its new state. + instrumentList.onMidiOutCommit = { [weak self] in + self?.menuBand?.toggleMIDIMode() + self?.refresh() + } instrumentList.onHover = { [weak self] prog in self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) self?.refresh() @@ -162,47 +163,11 @@ final class CollapsedPianoWaveformView: NSView { } } - // About row — bold "Menu Band" lead + secondary copy + - // aesthetic.computer chip link. Replicates the popover's - // about block in compact form. - aboutBody.font = NSFont.systemFont(ofSize: 10) - aboutBody.textColor = .secondaryLabelColor - aboutBody.maximumNumberOfLines = 2 - aboutBody.lineBreakMode = .byTruncatingTail - aboutBody.translatesAutoresizingMaskIntoConstraints = false - let aboutText = NSMutableAttributedString() - let bodyFont = NSFont.systemFont(ofSize: 10) - let boldFont = NSFont.systemFont(ofSize: 10, weight: .bold) - aboutText.append(NSAttributedString( - string: "Menu Band", - attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor])) - aboutText.append(NSAttributedString( - string: " — your menubar piano, an instrument woven into ", - attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor])) - aboutBody.attributedStringValue = aboutText - - let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) - let acTitle = NSAttributedString( - string: "aesthetic.computer", - attributes: [ - .font: NSFont.systemFont(ofSize: 10, weight: .semibold), - .foregroundColor: acPurple, - ]) - aboutLinkButton.attributedTitle = acTitle - aboutLinkButton.bezelStyle = .recessed - aboutLinkButton.controlSize = .small - aboutLinkButton.translatesAutoresizingMaskIntoConstraints = false - aboutLinkButton.target = self - aboutLinkButton.action = #selector(openAestheticClicked(_:)) - aboutLinkButton.toolTip = "https://aesthetic.computer" - addSubview(contentContainer) contentContainer.addSubview(instrumentList) contentContainer.addSubview(qwertyMap) contentContainer.addSubview(arrowsCluster) contentContainer.addSubview(modeStack) - contentContainer.addSubview(aboutBody) - contentContainer.addSubview(aboutLinkButton) installLiquidGlassBackgrounds() // Panel widens to fit either the chooser or the keyboard @@ -242,23 +207,13 @@ final class CollapsedPianoWaveformView: NSView { arrowsCluster.heightAnchor.constraint(equalToConstant: Self.arrowsRowHeight), // Mode picker (Notepat / Ableton) sits below the qwerty - // row. Centered horizontally; the about row beneath it - // pads the panel's bottom-leading fullscreen toggle. + // row. Centered horizontally and pinned to the bottom inset + // so the contentContainer's height resolves and the + // bottom-leading fullscreen toggle still has its strip. modeStack.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: Self.rowGap), modeStack.centerXAnchor.constraint(equalTo: contentContainer.centerXAnchor), modeStack.heightAnchor.constraint(equalToConstant: Self.modeRowHeight), - - // About row — wrapped Menu Band description on one line, - // aesthetic.computer link on the next. Pinned at the - // bottom inset so the fullscreen button stays visible - // bottom-leading. - aboutBody.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32), - aboutBody.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor, constant: -Self.edgePadding), - aboutBody.topAnchor.constraint(equalTo: modeStack.bottomAnchor, constant: Self.rowGap), - - aboutLinkButton.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32), - aboutLinkButton.topAnchor.constraint(equalTo: aboutBody.bottomAnchor, constant: 2), - aboutLinkButton.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -Self.edgePadding), + modeStack.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -Self.bottomInset), ]) refresh() @@ -311,6 +266,7 @@ final class CollapsedPianoWaveformView: NSView { // the giant selected number stays anchored to the committed // voice while the preview note plays a different program. instrumentList.selectedProgram = menuBand.effectiveMelodicProgram + instrumentList.midiModeActive = menuBand.midiMode arrowsCluster.accentColor = familyColor arrowsCluster.isDarkAppearance = isDark @@ -340,12 +296,6 @@ final class CollapsedPianoWaveformView: NSView { } } - @objc private func openAestheticClicked(_ sender: NSButton) { - if let url = URL(string: "https://aesthetic.computer") { - NSWorkspace.shared.open(url) - } - } - @objc private func whyKeymapClicked(_ sender: NSButton) { // Same fallback chain as the popover's whyKeymapButton — // bundled PDF first (offline-friendly), then the public URL. diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift index bdfbe90ff..2167cfbbf 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift @@ -48,7 +48,7 @@ final class ExpandedPianoWaveformView: NSView { var isPianoFocusActive: (() -> Bool)? var onHoverChanged: ((Bool) -> Void)? - private let pianoScale: CGFloat = 1.6 + private let pianoScale: CGFloat private let inset: CGFloat = 14 private let gap: CGFloat = 8 private let hintHeight: CGFloat = 20 @@ -69,7 +69,15 @@ final class ExpandedPianoWaveformView: NSView { titleLeftSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) titleRightSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) self.instrumentTitleRow = NSStackView(views: [titleLeftSpacer, instrumentReadout, titleRightSpacer]) - self.pianoView = PianoKeyboardView(menuBand: menuBand, pianoScale: pianoScale) + // Scale so the piano spans the right column of the panel + // exactly. Previously a fixed 1.6 scale made the keyboard + // wider than the glass panel and forced the panel to grow. + let basePianoWidth = KeyboardIconRenderer.withPianoWaveformKeyboard(keymap: menuBand.keymap) { + KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange).width + } + let computedPianoScale = Self.expandedPanelWidth / max(1, basePianoWidth) + self.pianoScale = computedPianoScale + self.pianoView = PianoKeyboardView(menuBand: menuBand, pianoScale: computedPianoScale) super.init(frame: NSRect(origin: .zero, size: .zero)) wantsLayer = true @@ -206,11 +214,11 @@ final class ExpandedPianoWaveformView: NSView { let bezelInset: CGFloat = 5 let titleSpacers = instrumentTitleRow.arrangedSubviews - // Total width is chooser (224) + gap + max(panel default, keyboard). - // The right column gets at least expandedPanelWidth so the keyboard - // and chord readout still feel roomy when the panel is paired with - // the chooser on the left. - let rightColumnWidth = max(keyboardSize.width + inset * 2, Self.expandedPanelWidth) + // Right column is fixed at the panel's intended width; the + // keyboard scales (above) to fit it, never the other way + // around — that keeps the keys visually inside the glass. + _ = keyboardSize // keep helper warm; sizing is column-driven now + let rightColumnWidth = Self.expandedPanelWidth let totalWidth = InstrumentListView.preferredWidth + gap + rightColumnWidth NSLayoutConstraint.activate([ @@ -580,8 +588,13 @@ final class ExpandedPianoWaveformView: NSView { private func updateInstrumentReadout() { guard let menuBand else { return } let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) - let title = GeneralMIDI.programNames[safe] - let familyColor = InstrumentListView.colorForProgram(safe) + // MIDI mode replaces the GM voice name with a MIDI label so + // the panel title matches the popover's "0 MIDI OUT" cue + // instead of leaving a stale instrument name on screen. + let title = menuBand.midiMode ? "MIDI" : GeneralMIDI.programNames[safe] + let familyColor = menuBand.midiMode + ? NSColor.controlAccentColor + : InstrumentListView.colorForProgram(safe) let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let textColor: NSColor = isDark ? .white : .black let shadow = NSShadow() diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift index cd356c3f1..94b7ed5a3 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift @@ -71,6 +71,14 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { var isFeatureEnabled: Bool { isEnabled } + /// Screen-coordinate frame of the floating panel when visible. + /// Used by AppDelegate's custom popover panel to align its left + /// edge against the floating panel's right edge. + var visiblePanelFrame: NSRect? { + guard let panel, panel.isVisible else { return nil } + return panel.frame + } + var onStepBackward: (() -> Void)? { get { pianoWaveformViewController.onStepBackward } set { pianoWaveformViewController.onStepBackward = newValue } @@ -304,15 +312,28 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true - panel.level = .floating + // .statusBar level + cross-space + full-screen-aux collection + // behavior keeps the floating piano panel rendered + clickable + // when the user swipes between Spaces or pulls Mission Control + // up over a focused fullscreen app — same trick clock / + // calculator widgets use to stay reachable from anywhere. + panel.level = .statusBar panel.animationBehavior = .none - panel.collectionBehavior = [.transient] + panel.collectionBehavior = [ + .transient, + .canJoinAllSpaces, + .fullScreenAuxiliary, + .stationary, + .ignoresCycle, + ] panel.hidesOnDeactivate = false panel.canHide = false - // Drag-by-background is off — the panel always pairs with the - // popover (snug-left), so a draggable body just lets clicks - // on the chooser / held-notes / button area accidentally - // move the window. + // Locked in place for now — positioning logic is in flux + // and a draggable panel just lets clicks on the chooser / + // held-notes / button area drift it off snug-pair with + // the popover. Both background-drag and title-bar drag + // are disabled. + panel.isMovable = false panel.isMovableByWindowBackground = false panel.acceptsMouseMovedEvents = true panel.titleVisibility = .hidden @@ -505,16 +526,14 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { } private func expandedFrame(size: NSSize, fallbackOrigin: NSPoint?) -> NSRect { - // Popover-snug positioning wins over the saved drag origin so - // the expanded panel pairs cleanly with the popover when both - // are on screen. - if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { - return NSRect( - x: popoverRect.minX - size.width, - y: popoverRect.maxY - size.height, - width: size.width, - height: size.height - ) + // When paired with the popover OR with a status item button + // available, snap the expanded panel to be centered under the + // piano keys section of the menubar piano. This keeps the + // panel + popover reading as parallel surfaces under the same + // menubar image rather than a fused strip beside the popover. + if popoverFrameProvider?() != nil, + let pianoFrame = anchoredCollapsedFrame(size: size) { + return pianoFrame } let origin = fallbackOrigin ?? savedExpandedOrigin return clampedFrame( @@ -525,18 +544,9 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { } private func collapsedFrame(size: NSSize) -> NSRect { - // Popover-snug positioning always wins over the user's - // dragged-to position. The floating panel pairs with the - // popover; honoring a stale custom origin while the popover - // is up would scatter the two surfaces. - if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { - return NSRect( - x: popoverRect.minX - size.width, - y: popoverRect.maxY - size.height, - width: size.width, - height: size.height - ) - } + // Always anchor under the piano keys section when we have a + // status item button — the user's dragged-to origin only + // applies when the panel is fully detached (no popover). guard let anchoredFrame = anchoredCollapsedFrame(size: size) else { return clampedFrame( origin: collapsedCustomOrigin ?? centeredOrigin(for: size), @@ -544,6 +554,11 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { preferredScreen: panel?.screen ?? NSScreen.main ) } + // If the popover is up, ignore the saved custom origin so the + // panel pairs cleanly under the piano keys. + if popoverFrameProvider?() != nil { + return anchoredFrame + } guard let collapsedCustomOrigin else { return anchoredFrame } return clampedFrame( origin: collapsedCustomOrigin, @@ -553,39 +568,47 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate { } private func anchoredCollapsedFrame(size: NSSize) -> NSRect? { - // Snug-left-of-popover takes precedence whenever the popover is - // on screen — the floating panel's right edge sits flush - // against the popover's left edge, tops aligned, so the two - // surfaces read as one continuous strip with the popover on - // the right and the floating piano on the left. - if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { - return NSRect( - x: popoverRect.minX - size.width, - y: popoverRect.maxY - size.height, - width: size.width, - height: size.height - ) - } - + // Single anchor for every show path: the panel's right edge + // sits at (predicted) popover-left minus a 6pt gap, top- + // aligned to the bottom of the menubar. Whether or not the + // popover is currently visible, the position is identical — + // so opening the panel via the LED chip and via the gear + // popover land it in the *same* spot, and a binary toggle + // never makes the panel hop. guard let button = statusItemButton, let buttonWindow = button.window else { return nil } let imgSize = KeyboardIconRenderer.imageSize let buttonBounds = button.bounds let xOffset = (buttonBounds.width - imgSize.width) / 2.0 - let pianoOriginX = xOffset + KeyboardIconRenderer.pad - let pianoWidth = imgSize.width - KeyboardIconRenderer.settingsW - - KeyboardIconRenderer.settingsGap - KeyboardIconRenderer.pad * 2 - - let localRect = NSRect(x: pianoOriginX, y: 0, width: pianoWidth, height: buttonBounds.height) - let windowRect = button.convert(localRect, to: nil) - let screenRect = buttonWindow.convertToScreen(windowRect) - return NSRect( - x: screenRect.origin.x, - y: screenRect.origin.y - size.height, - width: screenRect.width, - height: size.height - ) + let buttonScreenFrame = buttonWindow.convertToScreen(button.frame) + let menubarBottom = buttonScreenFrame.minY + + // Predicted popover.minX — derived from the gear icon's + // screen position the same way AppDelegate.showPopover() + // computes leftScreenX. Reuse the live popover frame when + // we have it (so a custom-positioned popover stays the + // anchor); otherwise fall back to the prediction. + let predictedPopoverLeft: CGFloat = { + if let popoverFrame = popoverFrameProvider?() { + return popoverFrame.minX + } + let latch = KeyboardIconRenderer.settingsRectPublic + let gearLocal = NSPoint(x: xOffset + latch.midX, y: 0) + let gearWindow = button.convert(gearLocal, to: nil) + let gearScreen = buttonWindow.convertPoint(toScreen: gearWindow) + return gearScreen.x + - MenuBandPopoverPanel.cornerRadius + - MenuBandPopoverPanel.arrowWidth / 2 - 2 + }() + // Negative gap = panel slides RIGHT past the popover-left + // anchor; positive gap = panel pulls left of it. -40 lands + // the panel snug under the right side of the menubar piano + // image, which is what reads best paired with the popover. + let gap: CGFloat = -40 + let x = predictedPopoverLeft - size.width - gap + let y = menubarBottom - size.height + return NSRect(x: x, y: y, width: size.width, height: size.height) } private func centeredOrigin(for size: NSSize) -> NSPoint { diff --git a/slab/menuband/bin/dev.sh b/slab/menuband/bin/dev.sh new file mode 100755 index 000000000..e7d0b3642 --- /dev/null +++ b/slab/menuband/bin/dev.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# dev.sh — fast debug build + run loop for Menu Band. +# +# This is NOT in-place hot-reload (see SCORE.md → "Why no hot-reload"), +# but it's a much faster iteration loop than install.sh: skips signing, +# skips launchd, skips bundle assembly, runs the unsigned debug binary +# directly. Edit code → Ctrl-C → re-run this script. Subsequent runs +# rebuild incrementally so the cycle is usually 2-5 seconds. +# +# For an automated rebuild-on-save loop with state-preserving restart, +# use bin/watch-reload.sh instead. + +set -euo pipefail + +CYAN=$'\033[1;36m' +YELLOW=$'\033[1;33m' +DIM=$'\033[2m' +RESET=$'\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Stop the launchd-managed production daemon so we don't end up with +# two menubar items fighting over the same status item slot. +PLIST="${HOME}/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist" +if [[ -f "${PLIST}" ]] && launchctl list | grep -q computer.aestheticcomputer.menuband; then + printf "%s• stopping launchd Menu Band%s\n" "$CYAN" "$RESET" + launchctl unload "${PLIST}" 2>/dev/null || true +fi +pkill -f "/MenuBand$" 2>/dev/null || true +sleep 0.3 + +cd "${PROJECT_DIR}" + +printf "%s• building + launching debug Menu Band…%s\n" "$CYAN" "$RESET" +printf "%s Ctrl-C to quit, then ./install.sh to restore the signed daemon%s\n\n" "$DIM" "$RESET" + +# `--scratch-path` keeps the debug build dir separate from the release +# tree install.sh writes into, so debug + release don't trip each other. +exec swift run -c debug \ + --scratch-path "${PROJECT_DIR}/.build-debug" \ + MenuBand diff --git a/slab/menuband/bin/watch-reload.sh b/slab/menuband/bin/watch-reload.sh new file mode 100755 index 000000000..de0961ee4 --- /dev/null +++ b/slab/menuband/bin/watch-reload.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# watch-reload.sh — fswatch Sources/, rebuild + relaunch on change, reopen +# the popover so iteration on liquid-glass UI feels close to live-reload. +# +# Usage: +# ./bin/watch-reload.sh # watch all Sources/ +# ./bin/watch-reload.sh popover # only refire on MenuBandPopover.swift +# +# Requires: fswatch (`brew install fswatch`). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +CYAN=$'\033[1;36m' +GREEN=$'\033[1;32m' +RED=$'\033[1;31m' +DIM=$'\033[2m' +RESET=$'\033[0m' + +if ! command -v fswatch >/dev/null 2>&1; then + printf "%sfswatch not installed%s — run: %sbrew install fswatch%s\n" \ + "$RED" "$RESET" "$CYAN" "$RESET" + exit 1 +fi + +# Filter the watched paths. Default = whole Sources tree. With "popover" +# arg, narrow to the popover file so heavy edits elsewhere don't trip the +# rebuild loop while you're iterating on chrome. +WATCH_PATHS=("${PROJECT_DIR}/Sources") +if [[ "${1:-}" == "popover" ]]; then + WATCH_PATHS=( + "${PROJECT_DIR}/Sources/MenuBand/MenuBandPopover.swift" + "${PROJECT_DIR}/Sources/MenuBand/Localization.swift" + ) +fi + +post_show_popover() { + # Distributed notification name registered in AppDelegate.swift — + # `handleShowPopoverNotification` re-opens the popover only if it + # isn't already shown, so repeated triggers don't flicker it shut. + /usr/bin/swift -e ' +import Foundation +DistributedNotificationCenter.default().post( + name: NSNotification.Name("computer.aestheticcomputer.menuband.showPopover"), + object: nil) +RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) +' >/dev/null 2>&1 || true +} + +reload() { + local started_at + started_at=$(date +%H:%M:%S) + printf "\n%s[watch %s] rebuild…%s\n" "$CYAN" "$started_at" "$RESET" + if ! ( cd "$PROJECT_DIR" && bash install.sh >/tmp/menuband-watch.log 2>&1 ); then + printf "%s[watch] install.sh failed — see /tmp/menuband-watch.log%s\n" \ + "$RED" "$RESET" + tail -15 /tmp/menuband-watch.log + return 1 + fi + # Give the new MenuBand instance a beat to register its observer + # before posting the show-popover notification. + sleep 0.6 + post_show_popover + printf "%s[watch] reloaded → popover reopened%s\n" "$GREEN" "$RESET" +} + +printf "%swatching:%s\n" "$CYAN" "$RESET" +for p in "${WATCH_PATHS[@]}"; do printf " %s%s%s\n" "$DIM" "$p" "$RESET"; done + +# Initial build so the first save isn't a no-op restart of stale state. +reload || true + +# `-or` recurses + outputs once per batch; `--latency 0.4` debounces +# rapid saves (editor write-then-rename, format-on-save) into one rebuild. +fswatch -or --latency 0.4 -e ".*/\.build/.*" -e ".*/\.swiftpm/.*" \ + "${WATCH_PATHS[@]}" | while read -r _; do + reload || true +done diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index bd19d3de5..314a60743 100644 --- a/system/public/menuband/index.html +++ b/system/public/menuband/index.html @@ -639,9 +639,9 @@

Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!

view source · by aesthetic.computer