diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index a80adc7e12..3b2d93e5ab 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -988,12 +988,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } // Bluetooth game controller. The Menu button toggles the popover - // (showPopover already closes it when shown); connect/disconnect - // refreshes the popover's gamepad status line. + // (showPopover already closes it when shown). The gamepad config UI + // now lives in the full-screen Keymap overlay and self-refreshes off + // the GameController connect/disconnect notifications, so no popover + // callback is needed here. gamepad.onTogglePopover = { [weak self] in self?.showPopover() } - gamepad.onConnectionChanged = { [weak self] in - self?.popoverVC?.refreshGamepadStatus() - } gamepad.start() // Pre-instance the popover VC + force its view to load now so the diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index 0cb57b47e2..16fc5c6ff9 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -128,8 +128,6 @@ final class MenuBandPopoverViewController: NSViewController { private var midiSwitch: NSSwitch! private var midiInlineLabel: NSTextField! private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack - private var gamepadSchemePopUp: NSPopUpButton! - private var gamepadStatusLabel: NSTextField! private var instrumentReadout: NSTextField! private var instrumentLabel: NSTextField! private var instrumentTitleRow: NSStackView! @@ -887,54 +885,10 @@ final class MenuBandPopoverViewController: NSViewController { keymapButton.toolTip = "Open the full-screen keymap (piano + QWERTY)" Self.outlineFooterButton(keymapButton, color: Self.keymapOutlineColor) - // Gamepad control scheme — switch live to feel out which mapping is - // most natural. The sublabel shows the connected controller's name. - stack.addArrangedSubview(makeSeparator()) - - let gamepadRow = NSStackView() - gamepadRow.orientation = .horizontal - gamepadRow.alignment = .centerY - gamepadRow.spacing = 10 - gamepadRow.distribution = .fill - - let gamepadLabelStack = NSStackView() - gamepadLabelStack.orientation = .vertical - gamepadLabelStack.alignment = .leading - gamepadLabelStack.spacing = 1 - let gamepadTitle = NSTextField(labelWithString: "Gamepad") - gamepadTitle.font = NSFont.systemFont(ofSize: 12, weight: .semibold) - gamepadTitle.textColor = .labelColor - gamepadStatusLabel = NSTextField(labelWithString: - GCController.controllers().first?.vendorName ?? "No controller connected") - gamepadStatusLabel.font = NSFont.systemFont(ofSize: 10) - gamepadStatusLabel.textColor = .secondaryLabelColor - gamepadStatusLabel.lineBreakMode = .byTruncatingTail - gamepadLabelStack.addArrangedSubview(gamepadTitle) - gamepadLabelStack.addArrangedSubview(gamepadStatusLabel) - - gamepadSchemePopUp = NSPopUpButton(frame: .zero, pullsDown: false) - gamepadSchemePopUp.controlSize = .small - gamepadSchemePopUp.font = NSFont.systemFont(ofSize: 11) - gamepadSchemePopUp.target = self - gamepadSchemePopUp.action = #selector(gamepadSchemeChanged(_:)) - for scheme in GamepadControlScheme.allCases { - gamepadSchemePopUp.addItem(withTitle: scheme.displayName) - gamepadSchemePopUp.lastItem?.representedObject = scheme.rawValue - } - gamepadSchemePopUp.selectItem(withTitle: GamepadDefaults.scheme.displayName) - - let gamepadSpacer = NSView() - gamepadSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - gamepadRow.addArrangedSubview(gamepadLabelStack) - gamepadRow.addArrangedSubview(gamepadSpacer) - gamepadRow.addArrangedSubview(gamepadSchemePopUp) - - // Tagged so the App Store screenshot capture (PopoverCapture.swift) - // can hide this row — gamepad config is noise in a marketing shot. - gamepadRow.identifier = NSUserInterfaceItemIdentifier("mb.gamepadRow") - stack.addArrangedSubview(gamepadRow) - gamepadRow.widthAnchor.constraint(equalTo: stack.widthAnchor, - constant: -16).isActive = true + // (Gamepad config moved to the full-screen Keymap overlay's bottom-right + // corner — see ExpandedPianoWaveformView.installGamepadCluster. It lives + // next to the large QWERTY/piano where a controller player is actually + // looking, and keeps this popover focused on the instrument.) // "About" — plain (default-tint) button, peer to Keymap and Quit. // Opens the identity/settings window (icon + flat-map language @@ -1221,7 +1175,6 @@ final class MenuBandPopoverViewController: NSViewController { } updateFocusShortcutControls() updatePlayPaletteShortcutControls() - refreshGamepadStatus() applyPopoverRootChrome() applyAppearanceToVisualizer() updateInstrumentReadout() @@ -1820,22 +1773,6 @@ final class MenuBandPopoverViewController: NSViewController { // MARK: - Gamepad - @objc private func gamepadSchemeChanged(_ sender: NSPopUpButton) { - guard let raw = sender.selectedItem?.representedObject as? String, - let scheme = GamepadControlScheme(rawValue: raw) else { return } - GamepadDefaults.scheme = scheme - NotificationCenter.default.post(name: .menuBandGamepadConfigChanged, object: nil) - } - - /// Refresh the gamepad row: keep the dropdown synced with the stored - /// scheme and show the connected controller (or "No controller"). Called - /// on popover open and on controller connect/disconnect. - func refreshGamepadStatus() { - guard isViewLoaded, gamepadSchemePopUp != nil else { return } - gamepadSchemePopUp.selectItem(withTitle: GamepadDefaults.scheme.displayName) - gamepadStatusLabel.stringValue = - GCController.controllers().first?.vendorName ?? "No controller connected" - } /// Hit the manifest at assets.aesthetic.computer/menuband/latest.json /// and stash the result for the About panel to surface. Cached for diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift index cf798211de..32f006ef51 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift @@ -7,6 +7,7 @@ import AppKit +import GameController final class ExpandedPianoWaveformView: NSView { static var shouldUseLiquidGlass: Bool { @@ -58,6 +59,10 @@ final class ExpandedPianoWaveformView: NSView { /// see at a glance which physical keys play which notes. Driven /// at 2× scale so it's legible at the floating palette's size. private let qwertyView = QwertyLayoutView() + /// Gamepad config — pinned bottom-right of this full-screen keymap overlay + /// (moved here from the popover). Scheme picker + connected-controller name. + private let gamepadSchemePopUp = NSPopUpButton(frame: .zero, pullsDown: false) + private let gamepadStatusLabel = NSTextField(labelWithString: "No controller connected") private var outlineBorderColor: NSColor = .separatorColor.withAlphaComponent(0.55) @@ -401,6 +406,7 @@ final class ExpandedPianoWaveformView: NSView { equalToConstant: QwertyLayoutView.intrinsicSize.height * 1.4 ), ]) + installGamepadCluster() } @available(*, unavailable) @@ -408,6 +414,67 @@ final class ExpandedPianoWaveformView: NSView { nil } + // MARK: - Gamepad config (bottom-right corner) + + /// Build the gamepad scheme picker + controller-status cluster and pin it + /// to the overlay's bottom-right corner. Self-contained: it drives the live + /// mapping through the global `GamepadDefaults` + `.menuBandGamepadConfigChanged` + /// (the same contract the popover used), and keeps its status fresh off the + /// GameController connect/disconnect notifications. + private func installGamepadCluster() { + let title = NSTextField(labelWithString: "Gamepad") + title.font = .systemFont(ofSize: 12, weight: .semibold) + title.textColor = .labelColor + gamepadStatusLabel.font = .systemFont(ofSize: 10) + gamepadStatusLabel.textColor = .secondaryLabelColor + gamepadStatusLabel.lineBreakMode = .byTruncatingTail + let labels = NSStackView(views: [title, gamepadStatusLabel]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 1 + + gamepadSchemePopUp.controlSize = .small + gamepadSchemePopUp.font = .systemFont(ofSize: 11) + gamepadSchemePopUp.target = self + gamepadSchemePopUp.action = #selector(gamepadSchemeChanged(_:)) + for scheme in GamepadControlScheme.allCases { + gamepadSchemePopUp.addItem(withTitle: scheme.displayName) + gamepadSchemePopUp.lastItem?.representedObject = scheme.rawValue + } + gamepadSchemePopUp.selectItem(withTitle: GamepadDefaults.scheme.displayName) + + let cluster = NSStackView(views: [labels, gamepadSchemePopUp]) + cluster.orientation = .horizontal + cluster.alignment = .centerY + cluster.spacing = 10 + cluster.translatesAutoresizingMaskIntoConstraints = false + addSubview(cluster) + NSLayoutConstraint.activate([ + cluster.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -(inset + 8)), + cluster.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -(inset + 8)), + ]) + + let nc = NotificationCenter.default + for name in [Notification.Name.GCControllerDidConnect, + .GCControllerDidDisconnect, .menuBandGamepadConfigChanged] { + nc.addObserver(self, selector: #selector(refreshGamepadStatus), name: name, object: nil) + } + refreshGamepadStatus() + } + + @objc private func gamepadSchemeChanged(_ sender: NSPopUpButton) { + guard let raw = sender.selectedItem?.representedObject as? String, + let scheme = GamepadControlScheme(rawValue: raw) else { return } + GamepadDefaults.scheme = scheme + NotificationCenter.default.post(name: .menuBandGamepadConfigChanged, object: nil) + } + + @objc private func refreshGamepadStatus() { + gamepadSchemePopUp.selectItem(withTitle: GamepadDefaults.scheme.displayName) + gamepadStatusLabel.stringValue = + GCController.controllers().first?.vendorName ?? "No controller connected" + } + private func installLiquidGlassBackgrounds() { guard Self.shouldUseLiquidGlass, #available(macOS 26.0, *) else { return }