Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Unify palette controllers and refine floating palette

- consolidate waveform and floating palette behavior into the unified palette flow
- refine floating palette presentation, interaction, and stable expanded sizing
- carry forward main-branch localization and keymap resource updates needed by the new palette path
- remove legacy menubar strip and piano palette controllers

authored by

Esteban Uribe and committed by
prompt.ac/@jeffrey
b213c125 bbc5b0c6

+1335 -2428
+7 -4
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 13 13 private var layoutToggleHotkey: GlobalHotkey? 14 14 private let popover = NSPopover() 15 15 private var popoverVC: MenuBandPopoverViewController? 16 - private lazy var pianoWaveformPalette = PianoWaveformPalette(menuBand: menuBand) 17 - private var floatingPlayPalette: PianoWaveformPalette { pianoWaveformPalette } 18 - private var waveformStrip: PianoWaveformPalette { pianoWaveformPalette } 16 + private lazy var pianoWaveformPalette = UnifiedPianoWaveformPalette(menuBand: menuBand) 17 + private var floatingPlayPalette: UnifiedPianoWaveformPalette { pianoWaveformPalette } 18 + private var waveformStrip: UnifiedPianoWaveformPalette { pianoWaveformPalette } 19 19 private var appBeforePopover: NSRunningApplication? 20 20 private var appBeforeFocusCapture: NSRunningApplication? 21 21 private var focusCaptureArmedByShortcut = false ··· 172 172 self.updateIcon() 173 173 self.popoverVC?.refreshHeldNotes() 174 174 self.floatingPlayPalette.refresh() 175 - self.waveformStrip.refreshAppearance() 175 + self.waveformStrip.refresh() 176 176 self.updateWaveformStrip() 177 177 } 178 178 menuBand.onInstrumentVisualChange = { [weak self] in ··· 314 314 keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags 315 315 ) 316 316 if consumed && isDown { 317 + if !self.menuBand.litNotes.isEmpty { 318 + self.waveformStrip.showIfNeeded() 319 + } 317 320 // Use the most-recent lit display note as the wave pivot 318 321 // so the ripple emanates from whichever key the user just 319 322 // played. `litNotes` is updated synchronously on this
+31 -11
slab/menuband/Sources/MenuBand/ArrowKeysIndicator.swift
··· 14 14 case horizontalPair 15 15 } 16 16 17 + enum Style { 18 + case standard 19 + case prominent 20 + } 21 + 17 22 private var pressed: Set<Int> = [] 18 23 private var hovered: Int? 19 24 private var trackingArea: NSTrackingArea? ··· 24 29 /// keyboard arrows drive (preview note while held, commit on 25 30 /// release). 26 31 var onClick: ((Int, Bool) -> Void)? 32 + var accentColor: NSColor = .controlAccentColor { didSet { needsDisplay = true } } 33 + var style: Style = .standard { didSet { needsDisplay = true } } 34 + var isDarkAppearance: Bool = false { didSet { needsDisplay = true } } 27 35 28 36 var displayMode: DisplayMode = .cluster { 29 37 didSet { ··· 68 76 needsDisplay = true 69 77 } 70 78 79 + override var mouseDownCanMoveWindow: Bool { false } 80 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 71 81 override var isFlipped: Bool { false } 82 + 72 83 73 84 // MARK: - Geometry 74 85 ··· 150 161 let isHover = (!lit && hovered == idx) 151 162 let path = NSBezierPath(roundedRect: kr, xRadius: radius, yRadius: radius) 152 163 if lit { 153 - NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() 164 + accentColor.withAlphaComponent(0.9).setFill() 154 165 path.fill() 155 166 } else if isHover { 156 - // Rollover wash — soft accent tint behind the glyph, 157 - // no fill on the rest of the cap so it reads as a 158 - // "ready to click" state, not "selected". 159 - NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() 167 + accentColor.withAlphaComponent(style == .prominent ? 0.24 : 0.18).setFill() 168 + path.fill() 169 + } else if style == .prominent { 170 + let idleFill = isDarkAppearance 171 + ? NSColor.white.withAlphaComponent(0.08) 172 + : NSColor.black.withAlphaComponent(0.06) 173 + idleFill.setFill() 160 174 path.fill() 161 175 } 162 176 let stroke: NSColor = lit 163 - ? NSColor.controlAccentColor 177 + ? accentColor 164 178 : isHover 165 - ? NSColor.controlAccentColor.withAlphaComponent(0.75) 166 - : NSColor.labelColor.withAlphaComponent(0.55) 179 + ? accentColor.withAlphaComponent(style == .prominent ? 0.9 : 0.75) 180 + : style == .prominent 181 + ? accentColor.withAlphaComponent(0.58) 182 + : NSColor.labelColor.withAlphaComponent(0.55) 167 183 stroke.setStroke() 168 - path.lineWidth = 0.8 184 + path.lineWidth = style == .prominent ? 1.0 : 0.8 169 185 path.stroke() 170 186 let glyphColor: NSColor = lit 171 187 ? .black 172 - : (isHover ? .controlAccentColor : NSColor.labelColor) 188 + : isHover 189 + ? accentColor 190 + : style == .prominent 191 + ? (isDarkAppearance ? NSColor.white.withAlphaComponent(0.96) : NSColor.black.withAlphaComponent(0.9)) 192 + : NSColor.labelColor 173 193 let attrs: [NSAttributedString.Key: Any] = [ 174 - .font: NSFont.systemFont(ofSize: 10, weight: .heavy), 194 + .font: NSFont.systemFont(ofSize: style == .prominent ? 10.5 : 10, weight: .heavy), 175 195 .foregroundColor: glyphColor, 176 196 ] 177 197 let s = NSAttributedString(string: glyphs[idx], attributes: attrs)
+292 -421
slab/menuband/Sources/MenuBand/FloatingPlayPalette.swift
··· 22 22 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 23 23 } 24 24 25 - final class FloatingPlayPaletteController: NSObject, NSWindowDelegate { 26 - enum DismissReason { 27 - case closeButton 28 - case shortcut 29 - case programmatic 30 - 31 - var shouldRestoreFocus: Bool { 32 - switch self { 33 - case .closeButton, .shortcut: 34 - return true 35 - case .programmatic: 36 - return false 37 - } 38 - } 25 + final class FloatingPlayPaletteViewController: NSViewController { 26 + enum DisplayMode { 27 + case expanded 28 + case collapsed 39 29 } 40 30 41 - private let menuBand: MenuBandController 42 - private let viewController: FloatingPlayPaletteViewController 43 - private var panel: FloatingPlayPalettePanel? 44 - private var keyMonitor: Any? 45 - private var appBeforeOpen: NSRunningApplication? 46 - private var isDismissing = false 47 - private var savedOrigin: NSPoint? 31 + private static let panelCornerRadius: CGFloat = 18 48 32 49 - private static let customOriginXKey = "notepat.floatingPlayPalette.customOriginX" 50 - private static let customOriginYKey = "notepat.floatingPlayPalette.customOriginY" 33 + private let containerView = NSView() 34 + private let paletteView: FloatingPlayPaletteView 35 + private let stripView: UnifiedWaveformStripView 36 + private let closeButton = NSButton() 37 + private let dockButton = NSButton() 38 + private let expandCollapseButton = NSButton() 39 + private var displayedView: NSView? 40 + private var displayMode: DisplayMode = .expanded 41 + private var isPresented = false 42 + private var trackingArea: NSTrackingArea? 43 + private var isMouseInsideView = false 44 + private weak var closeButtonGlassView: NSView? 45 + private weak var dockButtonGlassView: NSView? 46 + private weak var expandCollapseButtonGlassView: NSView? 47 + private let closeButtonSize: CGFloat = 30 48 + private let closeButtonCornerInset: CGFloat = 3 49 + private let gap: CGFloat = 8 51 50 52 - var onDismiss: (() -> Void)? 53 - var onFocusRelease: (() -> Void)? 54 - var onToggleKeymap: (() -> Void)? 55 - var onExpandCollapseToggle: (() -> Void)? { 56 - get { viewController.onExpandCollapseToggle } 57 - set { viewController.onExpandCollapseToggle = newValue } 51 + var onCloseRequested: (() -> Void)? 52 + 53 + var onDockRequested: (() -> Void)? 54 + 55 + var onTogglePresentationMode: (() -> Void)? 56 + 57 + var onStepBackward: (() -> Void)? { 58 + get { stripView.onStepBackward } 59 + set { stripView.onStepBackward = newValue } 58 60 } 59 - var isPianoFocusActive: (() -> Bool)? { 60 - get { viewController.isPianoFocusActive } 61 - set { viewController.isPianoFocusActive = newValue } 61 + 62 + var onStepForward: (() -> Void)? { 63 + get { stripView.onStepForward } 64 + set { stripView.onStepForward = newValue } 62 65 } 63 66 64 - var isShown: Bool { 65 - panel?.isVisible == true 67 + var onStepUp: (() -> Void)? { 68 + get { stripView.onStepUp } 69 + set { stripView.onStepUp = newValue } 66 70 } 67 71 68 - init(menuBand: MenuBandController) { 69 - self.menuBand = menuBand 70 - self.viewController = FloatingPlayPaletteViewController(menuBand: menuBand) 71 - self.savedOrigin = Self.loadSavedOrigin() 72 - super.init() 73 - self.viewController.onClose = { [weak self] in 74 - self?.dismiss(reason: .closeButton) 75 - } 72 + var onStepDown: (() -> Void)? { 73 + get { stripView.onStepDown } 74 + set { stripView.onStepDown = newValue } 76 75 } 77 76 78 - func toggleFromShortcut() { 79 - if isShown { 80 - dismiss(reason: .shortcut) 81 - } else { 82 - show() 83 - } 77 + var isPianoFocusActive: (() -> Bool)? { 78 + get { paletteView.isPianoFocusActive } 79 + set { paletteView.isPianoFocusActive = newValue } 84 80 } 85 81 86 - func showFromCommand(restoringTo previousApp: NSRunningApplication? = nil) { 87 - show(restoringTo: previousApp) 82 + init(menuBand: MenuBandController) { 83 + self.paletteView = FloatingPlayPaletteView(menuBand: menuBand) 84 + self.stripView = UnifiedWaveformStripView(menuBand: menuBand) 85 + super.init(nibName: nil, bundle: nil) 86 + preferredContentSize = preferredSize(for: displayMode) 88 87 } 89 88 90 - func show(restoringTo previousApp: NSRunningApplication? = nil) { 91 - if panel == nil { buildPanel() } 92 - guard let panel = panel else { return } 89 + @available(*, unavailable) 90 + required init?(coder: NSCoder) { 91 + nil 92 + } 93 93 94 - if panel.isVisible { 95 - panel.makeKeyAndOrderFront(nil) 96 - return 97 - } 94 + override func loadView() { 95 + view = containerView 96 + containerView.wantsLayer = true 97 + configureOverlayButton( 98 + closeButton, 99 + symbolName: "xmark", 100 + toolTip: "Close", 101 + action: #selector(closeClicked(_:)) 102 + ) 103 + configureOverlayButton( 104 + dockButton, 105 + symbolName: "menubar.dock.rectangle", 106 + toolTip: "Dock Below Menubar Piano", 107 + action: #selector(dockClicked(_:)) 108 + ) 109 + configureOverlayButton( 110 + expandCollapseButton, 111 + symbolName: "square.resize.down", 112 + toolTip: "Collapse", 113 + action: #selector(expandCollapseClicked(_:)) 114 + ) 115 + containerView.addSubview(closeButton) 116 + containerView.addSubview(dockButton) 117 + containerView.addSubview(expandCollapseButton) 118 + installOverlayGlassBackgrounds() 119 + NSLayoutConstraint.activate([ 120 + closeButton.topAnchor.constraint( 121 + equalTo: containerView.topAnchor, 122 + constant: Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 123 + ), 124 + closeButton.leadingAnchor.constraint( 125 + equalTo: containerView.leadingAnchor, 126 + constant: Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 127 + ), 128 + closeButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 129 + closeButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 98 130 99 - appBeforeOpen = previousApp ?? currentFrontmostOtherApp() 100 - viewController.refresh() 101 - panel.setFrame(restoredFrame(size: viewController.preferredContentSize), display: false) 131 + expandCollapseButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 132 + expandCollapseButton.trailingAnchor.constraint( 133 + equalTo: containerView.trailingAnchor, 134 + constant: -(Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset) 135 + ), 136 + expandCollapseButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 137 + expandCollapseButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 102 138 103 - NSApp.activate(ignoringOtherApps: true) 104 - panel.makeKeyAndOrderFront(nil) 105 - viewController.setPresented(true) 106 - installMonitors() 107 - } 108 - 109 - func dismiss(reason: DismissReason = .programmatic) { 110 - guard !isDismissing else { return } 111 - guard isShown || keyMonitor != nil else { return } 112 - 113 - isDismissing = true 114 - removeMonitors() 115 - viewController.setPresented(false) 116 - viewController.clearInteraction() 117 - menuBand.releaseAllHeldNotes() 118 - panel?.orderOut(nil) 119 - onDismiss?() 120 - if reason.shouldRestoreFocus { 121 - restorePreviousAppFocus() 122 - } 123 - appBeforeOpen = nil 124 - isDismissing = false 139 + dockButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 140 + dockButton.trailingAnchor.constraint(equalTo: expandCollapseButton.leadingAnchor, constant: -gap), 141 + dockButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 142 + dockButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 143 + ]) 144 + installTrackingArea() 145 + installDisplayedView() 146 + isMouseInsideView = isMouseInsideContainer() 147 + setOverlayControlsVisible(isMouseInsideView, animated: false) 125 148 } 126 149 127 150 func refresh() { 128 - guard isShown else { return } 129 - viewController.refresh() 130 - resizePanelToCurrentContent() 151 + paletteView.refresh() 152 + stripView.refresh() 153 + preferredContentSize = preferredSize(for: displayMode) 131 154 } 132 155 133 156 func clearInteraction() { 134 - viewController.clearInteraction() 157 + paletteView.clearInteraction() 135 158 } 136 159 137 - var isKeyboardFocused: Bool { 138 - panel?.isKeyWindow == true 160 + func setPresented(_ isPresented: Bool) { 161 + self.isPresented = isPresented 162 + updatePresentationState() 139 163 } 140 164 141 - func releaseKeyboardFocus() { 142 - guard isShown else { return } 143 - restorePreviousAppFocus() 144 - } 145 - 146 - private func buildPanel() { 147 - let p = FloatingPlayPalettePanel( 148 - contentRect: NSRect(origin: .zero, size: viewController.preferredContentSize), 149 - styleMask: [.titled, .closable, .fullSizeContentView], 150 - backing: .buffered, 151 - defer: false 152 - ) 153 - p.contentViewController = viewController 154 - p.delegate = self 155 - p.isOpaque = false 156 - p.backgroundColor = .clear 157 - p.hasShadow = true 158 - p.level = .floating 159 - p.animationBehavior = .none 160 - p.collectionBehavior = [.transient] 161 - p.hidesOnDeactivate = false 162 - p.canHide = false 163 - p.isMovableByWindowBackground = true 164 - p.acceptsMouseMovedEvents = true 165 - p.titleVisibility = .hidden 166 - p.titlebarAppearsTransparent = true 167 - p.isReleasedWhenClosed = false 168 - if let button = p.standardWindowButton(.closeButton) { 169 - button.isHidden = true 165 + func setDisplayMode(_ displayMode: DisplayMode) { 166 + guard self.displayMode != displayMode else { 167 + preferredContentSize = preferredSize(for: displayMode) 168 + updatePresentationState() 169 + return 170 170 } 171 - if let mini = p.standardWindowButton(.miniaturizeButton) { 172 - mini.isHidden = true 171 + self.displayMode = displayMode 172 + if isViewLoaded { 173 + installDisplayedView() 173 174 } 174 - if let zoom = p.standardWindowButton(.zoomButton) { 175 - zoom.isHidden = true 176 - } 177 - panel = p 175 + preferredContentSize = preferredSize(for: displayMode) 176 + updatePresentationState() 178 177 } 179 178 180 - private func installMonitors() { 181 - if keyMonitor == nil { 182 - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 183 - guard let self = self, self.panel?.isKeyWindow == true else { return event } 184 - let isDown = event.type == .keyDown 185 - if isDown && event.keyCode == 53 /* kVK_Escape */ { 186 - self.onFocusRelease?() 187 - return nil 188 - } 189 - if isDown && MenuBandShortcutPreferences.focusShortcut.matches(event: event) { 190 - self.onFocusRelease?() 191 - return nil 192 - } 193 - if isDown && MenuBandShortcut.layoutToggle.matches(event: event) { 194 - self.onToggleKeymap?() 195 - return nil 196 - } 197 - let consumed = self.menuBand.handleLocalKey( 198 - keyCode: event.keyCode, 199 - isDown: isDown, 200 - isRepeat: event.isARepeat, 201 - flags: event.modifierFlags 202 - ) 203 - if consumed { 204 - self.refresh() 205 - return nil 206 - } 207 - return event 208 - } 179 + private func installDisplayedView() { 180 + let nextView: NSView 181 + switch displayMode { 182 + case .expanded: 183 + nextView = paletteView 184 + case .collapsed: 185 + nextView = stripView 209 186 } 187 + 188 + guard displayedView !== nextView else { return } 189 + displayedView?.removeFromSuperview() 190 + nextView.translatesAutoresizingMaskIntoConstraints = false 191 + containerView.addSubview(nextView, positioned: .below, relativeTo: nil) 192 + NSLayoutConstraint.activate([ 193 + nextView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 194 + nextView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 195 + nextView.topAnchor.constraint(equalTo: containerView.topAnchor), 196 + nextView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), 197 + ]) 198 + displayedView = nextView 210 199 } 211 200 212 - private func removeMonitors() { 213 - if let m = keyMonitor { 214 - NSEvent.removeMonitor(m) 215 - keyMonitor = nil 201 + private func preferredSize(for displayMode: DisplayMode) -> NSSize { 202 + switch displayMode { 203 + case .expanded: 204 + return paletteView.fittingSize 205 + case .collapsed: 206 + return stripView.fittingSize 216 207 } 217 208 } 218 209 219 - private func resizePanelToCurrentContent() { 220 - guard let panel = panel, panel.isVisible else { return } 221 - let size = viewController.preferredContentSize 222 - let old = panel.frame 223 - guard abs(old.width - size.width) > 0.5 || abs(old.height - size.height) > 0.5 else { return } 224 - var frame = NSRect( 225 - x: old.origin.x, 226 - y: old.origin.y, 227 - width: size.width, 228 - height: size.height 229 - ) 230 - let visible = (panel.screen ?? NSScreen.main)?.visibleFrame 231 - ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 232 - frame = clamped(frame, to: visible) 233 - panel.setFrame(frame, display: true) 234 - persistPanelOrigin() 210 + private func updatePresentationState() { 211 + paletteView.setPresented(isPresented && displayMode == .expanded) 212 + stripView.setLive(isPresented && displayMode == .collapsed) 213 + let controlsHidden = false 214 + [closeButton, dockButton, expandCollapseButton, closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] 215 + .compactMap { $0 } 216 + .forEach { $0.isHidden = controlsHidden } 217 + updateExpandCollapseButtonAppearance() 218 + isMouseInsideView = isMouseInsideContainer() 219 + setOverlayControlsVisible(isMouseInsideView, animated: false) 220 + applyOverlayButtonAppearance() 235 221 } 236 222 237 - private func frameForCurrentMouseScreen(size: NSSize) -> NSRect { 238 - let mouse = NSEvent.mouseLocation 239 - let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } 240 - ?? NSScreen.main 241 - ?? NSScreen.screens.first 242 - let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 243 - let margin: CGFloat = 16 244 - let preferred = NSPoint( 245 - x: visible.midX - size.width / 2, 246 - y: visible.midY + visible.height * 0.12 - size.height / 2 247 - ) 248 - let x = min(max(preferred.x, visible.minX + margin), visible.maxX - size.width - margin) 249 - let y = min(max(preferred.y, visible.minY + margin), visible.maxY - size.height - margin) 250 - return NSRect(origin: NSPoint(x: x, y: y), size: size) 251 - } 252 - 253 - private func restoredFrame(size: NSSize) -> NSRect { 254 - guard let savedOrigin else { 255 - return frameForCurrentMouseScreen(size: size) 223 + private func configureOverlayButton( 224 + _ button: NSButton, 225 + symbolName: String, 226 + toolTip: String, 227 + action: Selector 228 + ) { 229 + let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 230 + button.translatesAutoresizingMaskIntoConstraints = false 231 + button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 232 + .withSymbolConfiguration(config) 233 + button.isBordered = false 234 + button.imagePosition = .imageOnly 235 + button.contentTintColor = .white.withAlphaComponent(0.92) 236 + button.toolTip = toolTip 237 + button.target = self 238 + button.action = action 239 + button.alphaValue = 0 240 + button.wantsLayer = true 241 + button.layer?.cornerRadius = closeButtonSize / 2 242 + button.layer?.borderWidth = 1 243 + if #available(macOS 10.15, *) { 244 + button.layer?.cornerCurve = .continuous 256 245 } 257 - let preferredScreen = NSScreen.screens.first(where: { $0.visibleFrame.contains(savedOrigin) }) 258 - ?? NSScreen.main 259 - ?? NSScreen.screens.first 260 - let visible = preferredScreen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 261 - return clamped(NSRect(origin: savedOrigin, size: size), to: visible) 262 246 } 263 247 264 - private func clamped(_ frame: NSRect, to visible: NSRect) -> NSRect { 265 - let margin: CGFloat = 16 266 - let x = min(max(frame.origin.x, visible.minX + margin), visible.maxX - frame.width - margin) 267 - let y = min(max(frame.origin.y, visible.minY + margin), visible.maxY - frame.height - margin) 268 - return NSRect(origin: NSPoint(x: x, y: y), size: frame.size) 248 + private func installOverlayGlassBackgrounds() { 249 + guard FloatingPlayPaletteView.shouldUseLiquidGlass, #available(macOS 26.0, *) else { return } 250 + self.closeButtonGlassView = installGlassBackground(for: closeButton) 251 + self.dockButtonGlassView = installGlassBackground(for: dockButton) 252 + self.expandCollapseButtonGlassView = installGlassBackground(for: expandCollapseButton) 269 253 } 270 254 271 - private func currentFrontmostOtherApp() -> NSRunningApplication? { 272 - let frontmost = NSWorkspace.shared.frontmostApplication 273 - guard frontmost?.bundleIdentifier != Bundle.main.bundleIdentifier else { return nil } 274 - return frontmost 255 + @available(macOS 26.0, *) 256 + private func installGlassBackground(for target: NSView) -> NSView { 257 + let glassView = FloatingPaletteGlassEffectView() 258 + glassView.translatesAutoresizingMaskIntoConstraints = false 259 + glassView.cornerRadius = closeButtonSize / 2 260 + containerView.addSubview(glassView, positioned: .below, relativeTo: target) 261 + NSLayoutConstraint.activate([ 262 + glassView.leadingAnchor.constraint(equalTo: target.leadingAnchor), 263 + glassView.trailingAnchor.constraint(equalTo: target.trailingAnchor), 264 + glassView.topAnchor.constraint(equalTo: target.topAnchor), 265 + glassView.bottomAnchor.constraint(equalTo: target.bottomAnchor), 266 + ]) 267 + return glassView 275 268 } 276 269 277 - private func restorePreviousAppFocus() { 278 - guard let app = appBeforeOpen, 279 - !app.isTerminated, 280 - app.bundleIdentifier != Bundle.main.bundleIdentifier else { return } 281 - app.activate(options: [.activateIgnoringOtherApps]) 282 - } 283 - 284 - func windowDidMove(_ notification: Notification) { 285 - persistPanelOrigin() 286 - } 287 - 288 - private func persistPanelOrigin() { 289 - guard let panel else { return } 290 - savedOrigin = panel.frame.origin 291 - let defaults = UserDefaults.standard 292 - defaults.set(panel.frame.origin.x, forKey: Self.customOriginXKey) 293 - defaults.set(panel.frame.origin.y, forKey: Self.customOriginYKey) 270 + private func setOverlayControlsVisible(_ isVisible: Bool, animated: Bool = true) { 271 + let alpha: CGFloat = isVisible ? 1.0 : 0.0 272 + let views = [closeButton, dockButton, expandCollapseButton, closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] 273 + .compactMap { $0 } 274 + if animated { 275 + NSAnimationContext.runAnimationGroup { context in 276 + context.duration = 0.12 277 + closeButton.animator().alphaValue = alpha 278 + dockButton.animator().alphaValue = alpha 279 + expandCollapseButton.animator().alphaValue = alpha 280 + closeButtonGlassView?.animator().alphaValue = alpha 281 + dockButtonGlassView?.animator().alphaValue = alpha 282 + expandCollapseButtonGlassView?.animator().alphaValue = alpha 283 + } 284 + } else { 285 + views.forEach { $0.alphaValue = alpha } 286 + } 294 287 } 295 288 296 - private static func loadSavedOrigin() -> NSPoint? { 297 - let defaults = UserDefaults.standard 298 - guard defaults.object(forKey: customOriginXKey) != nil, 299 - defaults.object(forKey: customOriginYKey) != nil else { return nil } 300 - return NSPoint( 301 - x: defaults.double(forKey: customOriginXKey), 302 - y: defaults.double(forKey: customOriginYKey) 289 + private func installTrackingArea() { 290 + if let trackingArea { 291 + containerView.removeTrackingArea(trackingArea) 292 + } 293 + let trackingArea = NSTrackingArea( 294 + rect: .zero, 295 + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], 296 + owner: self, 297 + userInfo: nil 303 298 ) 299 + containerView.addTrackingArea(trackingArea) 300 + self.trackingArea = trackingArea 304 301 } 305 302 306 - deinit { 307 - dismiss(reason: .programmatic) 303 + override func mouseEntered(with event: NSEvent) { 304 + isMouseInsideView = true 305 + setOverlayControlsVisible(true) 308 306 } 309 - } 310 307 311 - private final class FloatingPlayPaletteViewController: NSViewController { 312 - private let paletteView: FloatingPlayPaletteView 313 - var onClose: (() -> Void)? { 314 - get { paletteView.onClose } 315 - set { paletteView.onClose = newValue } 316 - } 317 - var onExpandCollapseToggle: (() -> Void)? { 318 - get { paletteView.onExpandCollapseToggle } 319 - set { paletteView.onExpandCollapseToggle = newValue } 320 - } 321 - var isPianoFocusActive: (() -> Bool)? { 322 - get { paletteView.isPianoFocusActive } 323 - set { paletteView.isPianoFocusActive = newValue } 324 - } 325 - var onHoverChanged: ((Bool) -> Void)? { 326 - get { paletteView.onHoverChanged } 327 - set { paletteView.onHoverChanged = newValue } 308 + override func mouseExited(with event: NSEvent) { 309 + isMouseInsideView = false 310 + setOverlayControlsVisible(false) 328 311 } 329 312 330 - init(menuBand: MenuBandController) { 331 - self.paletteView = FloatingPlayPaletteView(menuBand: menuBand) 332 - super.init(nibName: nil, bundle: nil) 333 - preferredContentSize = paletteView.fittingSize 313 + private func isMouseInsideContainer() -> Bool { 314 + guard let window = containerView.window else { return false } 315 + let location = containerView.convert(window.mouseLocationOutsideOfEventStream, from: nil) 316 + return containerView.bounds.contains(location) 334 317 } 335 318 336 - @available(*, unavailable) 337 - required init?(coder: NSCoder) { 338 - nil 319 + private func applyOverlayButtonAppearance() { 320 + let effectiveView: NSView 321 + switch displayMode { 322 + case .expanded: 323 + effectiveView = paletteView 324 + case .collapsed: 325 + effectiveView = stripView 326 + } 327 + let isDark = effectiveView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 328 + let tintColor = paletteView.paletteTintColor 329 + if FloatingPlayPaletteView.shouldUseLiquidGlass, #available(macOS 26.0, *) { 330 + for view in [closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] { 331 + (view as? NSGlassEffectView)?.style = .clear 332 + (view as? NSGlassEffectView)?.tintColor = tintColor.withAlphaComponent(0.34) 333 + } 334 + for button in [closeButton, dockButton, expandCollapseButton] { 335 + button.layer?.backgroundColor = NSColor.clear.cgColor 336 + button.layer?.borderColor = NSColor.clear.cgColor 337 + } 338 + } else { 339 + let border = NSColor.white.withAlphaComponent(0.28).cgColor 340 + let background = NSColor.windowBackgroundColor.withAlphaComponent(isDark ? 0.18 : 0.22).cgColor 341 + for button in [closeButton, dockButton, expandCollapseButton] { 342 + button.layer?.backgroundColor = background 343 + button.layer?.borderColor = border 344 + } 345 + } 339 346 } 340 347 341 - override func loadView() { 342 - view = paletteView 348 + private func updateExpandCollapseButtonAppearance() { 349 + let symbolName = displayMode == .expanded ? "square.resize.down" : "square.resize.up" 350 + let toolTip = displayMode == .expanded ? "Collapse" : "Expand floating piano" 351 + let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 352 + expandCollapseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 353 + .withSymbolConfiguration(config) 354 + expandCollapseButton.toolTip = toolTip 343 355 } 344 356 345 - func refresh() { 346 - paletteView.refresh() 347 - preferredContentSize = paletteView.fittingSize 357 + @objc private func closeClicked(_ sender: NSButton) { 358 + onCloseRequested?() 348 359 } 349 360 350 - func clearInteraction() { 351 - paletteView.clearInteraction() 361 + @objc private func dockClicked(_ sender: NSButton) { 362 + onDockRequested?() 352 363 } 353 364 354 - func setPresented(_ isPresented: Bool) { 355 - paletteView.setPresented(isPresented) 365 + @objc private func expandCollapseClicked(_ sender: NSButton) { 366 + onTogglePresentationMode?() 356 367 } 357 368 } 358 369 ··· 371 382 return FloatingPaletteVisualStyleOverride(rawValue: defaultsValue) 372 383 } 373 384 374 - private static var shouldUseLiquidGlass: Bool { 385 + static var shouldUseLiquidGlass: Bool { 375 386 switch visualStyleOverride { 376 387 case .liquid: 377 388 if #available(macOS 26.0, *) { return true } ··· 398 409 private let instrumentReadout = NSTextField(labelWithString: "") 399 410 private let instrumentTitleRow: NSStackView 400 411 private let pianoView: FloatingPianoView 401 - private let closeButton = NSButton() 402 - private let dockButton = NSButton() 403 - private let expandCollapseButton = NSButton() 404 412 private let shortcutHintRow = NSStackView() 405 413 private let focusHintLabel = NSTextField(labelWithString: "") 406 414 private let layoutHintLabel = NSTextField(labelWithString: "") 407 - private weak var closeButtonGlassView: NSView? 408 - private weak var dockButtonGlassView: NSView? 409 - private weak var expandCollapseButtonGlassView: NSView? 410 415 private weak var paletteGlassView: NSView? 411 416 private weak var waveformGlassView: NSView? 412 417 private weak var waveformSectionGlassView: NSView? ··· 418 423 /// at 2× scale so it's legible at the floating palette's size. 419 424 private let qwertyView = QwertyLayoutView() 420 425 421 - var onClose: (() -> Void)? 422 - var onExpandCollapseToggle: (() -> Void)? 423 426 var isPianoFocusActive: (() -> Bool)? 424 427 var onHoverChanged: ((Bool) -> Void)? 425 428 426 429 private let pianoScale: CGFloat = 1.6 427 430 private let inset: CGFloat = 14 428 431 private let gap: CGFloat = 8 429 - private let closeButtonSize: CGFloat = 30 430 - private let closeButtonCornerInset: CGFloat = 3 431 432 private let hintHeight: CGFloat = 20 432 433 private let heldNotesRowHeight: CGFloat = 26 433 434 private let chordCandidatesRowHeight: CGFloat = 30 434 435 private var waveformHeightConstraint: NSLayoutConstraint? 435 436 private var trackingArea: NSTrackingArea? 436 - private var isExpanded = true 437 437 private static let panelCornerRadius: CGFloat = 18 438 438 private static let sectionCornerRadius: CGFloat = 14 439 439 private static let waveformClipCornerRadius: CGFloat = 12 440 + private static let expandedPanelWidth: CGFloat = 440 440 441 441 442 init(menuBand: MenuBandController) { 442 443 self.menuBand = menuBand ··· 500 501 instrumentTitleRow.spacing = 0 501 502 instrumentTitleRow.translatesAutoresizingMaskIntoConstraints = false 502 503 pianoView.translatesAutoresizingMaskIntoConstraints = false 503 - closeButton.translatesAutoresizingMaskIntoConstraints = false 504 - dockButton.translatesAutoresizingMaskIntoConstraints = false 505 - expandCollapseButton.translatesAutoresizingMaskIntoConstraints = false 506 504 shortcutHintRow.orientation = .horizontal 507 505 shortcutHintRow.alignment = .centerY 508 506 shortcutHintRow.distribution = .fill ··· 555 553 layoutHintLabel.alignment = .left 556 554 focusHintLabel.alignment = .right 557 555 updateShortcutHint() 558 - 559 - configureCornerButton( 560 - closeButton, 561 - symbolName: "xmark", 562 - toolTip: "Close", 563 - action: #selector(closeClicked(_:)) 564 - ) 565 - configureCornerButton( 566 - dockButton, 567 - symbolName: "menubar.dock.rectangle", 568 - toolTip: "Dock Below Menubar Piano", 569 - action: #selector(dockClicked(_:)) 570 - ) 571 - configureCornerButton( 572 - expandCollapseButton, 573 - symbolName: "square.resize.down", 574 - toolTip: "Collapse", 575 - action: #selector(expandCollapseClicked(_:)) 576 - ) 577 - updateExpandCollapseButton() 578 - addSubview(closeButton) 579 - addSubview(dockButton) 580 - addSubview(expandCollapseButton) 581 556 installLiquidGlassBackgrounds() 582 557 583 558 let keyboardSize = self.keyboardSize() ··· 589 564 let titleSpacers = instrumentTitleRow.arrangedSubviews 590 565 591 566 NSLayoutConstraint.activate([ 592 - widthAnchor.constraint(equalToConstant: keyboardSize.width + inset * 2), 567 + widthAnchor.constraint( 568 + equalToConstant: max(keyboardSize.width + inset * 2, Self.expandedPanelWidth) 569 + ), 593 570 594 571 contentStack.topAnchor.constraint(equalTo: topAnchor, constant: inset), 595 572 contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), ··· 599 576 waveformSection.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 600 577 waveformSection.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 601 578 602 - closeButton.topAnchor.constraint( 603 - equalTo: topAnchor, 604 - constant: Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 605 - ), 606 - closeButton.leadingAnchor.constraint( 607 - equalTo: leadingAnchor, 608 - constant: Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 609 - ), 610 - closeButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 611 - closeButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 612 - 613 - expandCollapseButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 614 - expandCollapseButton.trailingAnchor.constraint( 615 - equalTo: trailingAnchor, 616 - constant: -(Self.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset) 617 - ), 618 - expandCollapseButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 619 - expandCollapseButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 620 - 621 - dockButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 622 - dockButton.trailingAnchor.constraint(equalTo: expandCollapseButton.leadingAnchor, constant: -gap), 623 - dockButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 624 - dockButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 625 - 626 579 heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesRow.centerXAnchor), 627 580 heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesRow.centerYAnchor), 628 581 heldNotesStack.leadingAnchor.constraint(greaterThanOrEqualTo: heldNotesRow.leadingAnchor, constant: 6), ··· 660 613 instrumentTitleRow.leadingAnchor.constraint(equalTo: waveformSection.leadingAnchor, constant: 6), 661 614 instrumentTitleRow.trailingAnchor.constraint(equalTo: waveformSection.trailingAnchor, constant: -6), 662 615 instrumentTitleRow.bottomAnchor.constraint(equalTo: waveformSection.bottomAnchor, constant: -6), 663 - 664 - pianoView.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 665 - pianoView.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 666 616 667 617 shortcutHintRow.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 668 618 shortcutHintRow.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), ··· 706 656 below: waveformSection, 707 657 cornerRadius: Self.sectionCornerRadius 708 658 ) 709 - self.closeButtonGlassView = installGlassBackground( 710 - matchedTo: closeButton, 711 - below: closeButton, 712 - cornerRadius: closeButtonSize / 2 713 - ) 714 - self.dockButtonGlassView = installGlassBackground( 715 - matchedTo: dockButton, 716 - below: dockButton, 717 - cornerRadius: closeButtonSize / 2 718 - ) 719 - self.expandCollapseButtonGlassView = installGlassBackground( 720 - matchedTo: expandCollapseButton, 721 - below: expandCollapseButton, 722 - cornerRadius: closeButtonSize / 2 723 - ) 724 659 self.pianoGlassView = installGlassBackground( 725 660 matchedTo: pianoView, 726 661 below: pianoView, ··· 774 709 775 710 override func mouseEntered(with event: NSEvent) { 776 711 super.mouseEntered(with: event) 777 - setCloseControlVisible(true) 778 712 onHoverChanged?(true) 779 713 } 780 714 781 715 override func mouseExited(with event: NSEvent) { 782 716 super.mouseExited(with: event) 783 - setCloseControlVisible(false) 784 717 onHoverChanged?(false) 785 718 } 786 719 ··· 840 773 waveformView.alphaValue = (menuBand?.midiMode ?? false) ? 0.35 : 1.0 841 774 } 842 775 843 - private func setCloseControlVisible(_ isVisible: Bool) { 844 - let alpha: CGFloat = isVisible ? 1.0 : 0.0 845 - NSAnimationContext.runAnimationGroup { context in 846 - context.duration = 0.12 847 - closeButton.animator().alphaValue = alpha 848 - closeButtonGlassView?.animator().alphaValue = alpha 849 - dockButton.animator().alphaValue = alpha 850 - dockButtonGlassView?.animator().alphaValue = alpha 851 - expandCollapseButton.animator().alphaValue = alpha 852 - expandCollapseButtonGlassView?.animator().alphaValue = alpha 853 - } 854 - } 855 - 856 776 private func applyAppearanceToVisualizer() { 857 777 let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 858 778 waveformView.setLightMode(!isDark) ··· 867 787 868 788 private func applyWaveformTint() { 869 789 guard let menuBand else { return } 870 - let paletteTint: NSColor 871 790 if menuBand.midiMode { 872 791 waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 873 792 waveformView.setBaseColor(.controlAccentColor) 874 793 waveformSection.layer?.borderColor = NSColor.controlAccentColor 875 794 .withAlphaComponent(0.24).cgColor 876 - paletteTint = NSColor.controlAccentColor.withAlphaComponent(0.20) 877 795 } else { 878 796 waveformView.setDotMatrix(nil) 879 797 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) ··· 881 799 waveformView.setBaseColor(familyColor) 882 800 waveformSection.layer?.borderColor = familyColor 883 801 .withAlphaComponent(0.22).cgColor 884 - paletteTint = familyColor.withAlphaComponent(0.16) 885 802 } 886 803 if #available(macOS 26.0, *) { 804 + let paletteTint = paletteTintColor 887 805 for view in [paletteGlassView, waveformSectionGlassView, pianoGlassView, keymapGlassView, hintGlassView] { 888 806 (view as? NSGlassEffectView)?.tintColor = paletteTint 889 807 } 890 - for view in [closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] { 891 - (view as? NSGlassEffectView)?.style = .clear 892 - (view as? NSGlassEffectView)?.tintColor = paletteTint.withAlphaComponent(0.34) 893 - } 894 - for button in [closeButton, dockButton, expandCollapseButton] { 895 - button.layer?.backgroundColor = NSColor.clear.cgColor 896 - button.layer?.borderColor = NSColor.clear.cgColor 897 - } 898 - } else { 899 - for button in [closeButton, dockButton, expandCollapseButton] { 900 - button.layer?.backgroundColor = NSColor.windowBackgroundColor 901 - .withAlphaComponent(0.22) 902 - .cgColor 903 - button.layer?.borderColor = NSColor.white.withAlphaComponent(0.28).cgColor 904 - } 905 808 } 906 809 } 907 810 811 + var paletteTintColor: NSColor { 812 + guard let menuBand else { return NSColor.controlAccentColor.withAlphaComponent(0.20) } 813 + if menuBand.midiMode { 814 + return NSColor.controlAccentColor.withAlphaComponent(0.20) 815 + } 816 + let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 817 + return InstrumentListView.colorForProgram(safe).withAlphaComponent(0.16) 818 + } 819 + 908 820 private func keyboardSize() -> NSSize { 909 821 withFloatingPaletteKeyboard(menuBand: menuBand) { 910 822 let piano = KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange) ··· 1045 957 : "Focus Piano: \(focusShortcut)" 1046 958 } 1047 959 1048 - @objc private func closeClicked(_ sender: NSButton) { 1049 - onClose?() 1050 - } 1051 - 1052 - @objc private func dockClicked(_ sender: NSButton) { 1053 - NSSound.beep() 1054 - } 1055 - 1056 - @objc private func expandCollapseClicked(_ sender: NSButton) { 1057 - onExpandCollapseToggle?() 1058 - } 1059 - 1060 - private func configureCornerButton(_ button: NSButton, 1061 - symbolName: String, 1062 - toolTip: String, 1063 - action: Selector) { 1064 - let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 1065 - button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 1066 - .withSymbolConfiguration(config) 1067 - button.isBordered = false 1068 - button.imagePosition = .imageOnly 1069 - button.contentTintColor = .white.withAlphaComponent(0.92) 1070 - button.toolTip = toolTip 1071 - button.target = self 1072 - button.action = action 1073 - button.alphaValue = 0 1074 - button.wantsLayer = true 1075 - button.layer?.cornerRadius = closeButtonSize / 2 1076 - button.layer?.borderWidth = 1 1077 - if #available(macOS 10.15, *) { 1078 - button.layer?.cornerCurve = .continuous 1079 - } 1080 - } 1081 - 1082 - private func updateExpandCollapseButton() { 1083 - let symbolName = isExpanded ? "square.resize.down" : "square.resize.up" 1084 - let toolTip = isExpanded ? "Collapse" : "Expand" 1085 - let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 1086 - expandCollapseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 1087 - .withSymbolConfiguration(config) 1088 - expandCollapseButton.toolTip = toolTip 1089 - } 1090 960 } 1091 961 1092 962 private final class FloatingPianoView: NSView { ··· 1119 989 } 1120 990 1121 991 override var acceptsFirstResponder: Bool { true } 992 + override var mouseDownCanMoveWindow: Bool { false } 1122 993 1123 994 func refreshLayout() { 1124 995 let preferredSize = preferredSize()
-1812
slab/menuband/Sources/MenuBand/MenuBarWaveformStrip.swift
··· 1 - import AppKit 2 - 3 - private enum StripVisualStyleOverride: String { 4 - case automatic 5 - case liquid 6 - case legacy 7 - 8 - init(rawValue: String?) { 9 - switch rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { 10 - case Self.liquid.rawValue: 11 - self = .liquid 12 - case Self.legacy.rawValue: 13 - self = .legacy 14 - default: 15 - self = .automatic 16 - } 17 - } 18 - } 19 - 20 - private final class LegacyMenuBarWaveformStripPanel: NSPanel { 21 - var onDragBegan: (() -> Void)? 22 - var onDragMoved: ((NSPoint) -> Void)? 23 - var onDragEnded: (() -> Void)? 24 - 25 - private var dragStartMouseLocation: NSPoint? 26 - private var dragStartOrigin: NSPoint = .zero 27 - private var isDraggingStrip = false 28 - 29 - override func sendEvent(_ event: NSEvent) { 30 - switch event.type { 31 - case .leftMouseDown: 32 - if event.clickCount == 1 { 33 - dragStartMouseLocation = convertToScreen( 34 - NSRect(origin: event.locationInWindow, size: .zero) 35 - ).origin 36 - dragStartOrigin = frame.origin 37 - isDraggingStrip = false 38 - } 39 - case .leftMouseDragged: 40 - guard let startMouseLocation = dragStartMouseLocation else { break } 41 - let current = convertToScreen( 42 - NSRect(origin: event.locationInWindow, size: .zero) 43 - ).origin 44 - let deltaX = current.x - startMouseLocation.x 45 - let deltaY = current.y - startMouseLocation.y 46 - if !isDraggingStrip { 47 - isDraggingStrip = true 48 - onDragBegan?() 49 - } 50 - onDragMoved?(NSPoint(x: dragStartOrigin.x + deltaX, y: dragStartOrigin.y + deltaY)) 51 - case .leftMouseUp: 52 - dragStartMouseLocation = nil 53 - if isDraggingStrip { 54 - isDraggingStrip = false 55 - onDragEnded?() 56 - } 57 - default: 58 - break 59 - } 60 - super.sendEvent(event) 61 - } 62 - } 63 - 64 - private final class LegacyMenuBarWaveformStrip: NSObject, MenuBarWaveformStripImplementation { 65 - private let menuBand: MenuBandController 66 - private let waveformView = WaveformView() 67 - private let waveformBezel = NSView() 68 - private var waveformBezelHeightConstraint: NSLayoutConstraint? 69 - private let instrumentArrows = ArrowKeysIndicator() 70 - private let instrumentRowContainer = NSView() 71 - private let instrumentLabel = NSTextField(labelWithString: "") 72 - private let closeButton = NSButton() 73 - private let dockButton = NSButton() 74 - private let expandButton = NSButton() 75 - private let heldNotesContainer = NSView() 76 - private let heldNotesStack = NSStackView() 77 - private var panel: NSPanel? 78 - private weak var statusButton: NSStatusBarButton? 79 - private var isPointerInsideStrip = false 80 - var onStepBackward: (() -> Void)? 81 - var onStepForward: (() -> Void)? 82 - var onStepUp: (() -> Void)? 83 - var onStepDown: (() -> Void)? 84 - var onExpandRequested: (() -> Void)? 85 - 86 - private let hideDelay: TimeInterval = 2.0 87 - private var hideWorkItem: DispatchWorkItem? 88 - private let customOriginXKey = "notepat.waveformStrip.customOriginX" 89 - private let customOriginYKey = "notepat.waveformStrip.customOriginY" 90 - private var customOrigin: NSPoint? 91 - 92 - private static let waveformAspectRatio: CGFloat = InstrumentListView.preferredWidth / 64 93 - private static let controlButtonSize: CGFloat = 20 94 - private static let footerHeight: CGFloat = 46 95 - private static let heldNotesRowHeight: CGFloat = 14 96 - private static let instrumentRowHeight: CGFloat = 22 97 - private static let fadeInDuration: TimeInterval = 0.18 98 - private static let fadeOutDuration: TimeInterval = 0.18 99 - private static let stripLevel = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue - 1) 100 - 101 - private var slideLink: CVDisplayLink? 102 - private var slideStartTime: CFTimeInterval = 0 103 - private var slideFromY: CGFloat = 0 104 - private var slideToY: CGFloat = 0 105 - private var slideCompletion: (() -> Void)? 106 - private var isSliding = false 107 - 108 - var isShown: Bool { panel?.isVisible == true } 109 - var isDocked: Bool { customOrigin == nil } 110 - 111 - var suppressed: Bool = false { 112 - didSet { 113 - guard suppressed != oldValue else { return } 114 - if suppressed { hide(animated: false) } 115 - } 116 - } 117 - 118 - init(menuBand: MenuBandController) { 119 - self.menuBand = menuBand 120 - super.init() 121 - customOrigin = loadCustomOrigin() 122 - waveformView.menuBand = menuBand 123 - waveformView.setSurfaceStyle(.standard) 124 - waveformBezel.wantsLayer = true 125 - waveformBezel.layer?.cornerRadius = 6 126 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 127 - waveformBezel.layer?.borderWidth = 1 128 - heldNotesStack.orientation = .horizontal 129 - heldNotesStack.alignment = .centerY 130 - heldNotesStack.spacing = 4 131 - instrumentLabel.drawsBackground = false 132 - instrumentLabel.lineBreakMode = .byTruncatingTail 133 - instrumentArrows.displayMode = .horizontalPair 134 - instrumentArrows.toolTip = "Change instrument" 135 - instrumentArrows.onClick = { [weak self] dir, isDown in 136 - guard isDown else { return } 137 - self?.registerArrowInput() 138 - switch dir { 139 - case 0: self?.onStepBackward?() 140 - case 1: self?.onStepForward?() 141 - default: break 142 - } 143 - } 144 - configureControlButton( 145 - closeButton, 146 - symbolName: "xmark", 147 - toolTip: "Close", 148 - action: #selector(closeButtonClicked(_:)) 149 - ) 150 - configureControlButton( 151 - dockButton, 152 - symbolName: "menubar.dock.rectangle", 153 - toolTip: "Dock Below Menubar Piano", 154 - action: #selector(dockButtonClicked(_:)) 155 - ) 156 - configureControlButton( 157 - expandButton, 158 - symbolName: "square.resize.up", 159 - toolTip: "Expand floating piano", 160 - action: #selector(expandButtonClicked(_:)) 161 - ) 162 - } 163 - 164 - deinit { 165 - stopSlide() 166 - } 167 - 168 - private func handleStripDragBegan() { 169 - cancelPendingHide() 170 - stopSlide() 171 - isSliding = false 172 - } 173 - 174 - private func handleStripDragEnded() { 175 - if menuBand.litNotes.isEmpty { 176 - scheduleHide() 177 - } 178 - } 179 - 180 - private func handleStripMouseEntered() { 181 - guard !isPointerInsideStrip else { return } 182 - isPointerInsideStrip = true 183 - setControlsVisible(true) 184 - cancelPendingHide() 185 - } 186 - 187 - private func handleStripMouseExited() { 188 - guard isPointerInsideStrip else { return } 189 - isPointerInsideStrip = false 190 - setControlsVisible(false) 191 - if menuBand.litNotes.isEmpty { 192 - scheduleHide() 193 - } 194 - } 195 - 196 - func warmUp() { 197 - if panel == nil { buildPanel() } 198 - } 199 - 200 - func showIfNeeded() { 201 - guard !suppressed else { return } 202 - cancelPendingHide() 203 - if !isShown && !isSliding { show() } 204 - } 205 - 206 - func scheduleHide() { 207 - guard !isPointerInsideStrip else { return } 208 - cancelPendingHide() 209 - let work = DispatchWorkItem { [weak self] in 210 - self?.hide(animated: true) 211 - } 212 - hideWorkItem = work 213 - DispatchQueue.main.asyncAfter(deadline: .now() + hideDelay, execute: work) 214 - } 215 - 216 - func dismiss() { 217 - cancelPendingHide() 218 - stopSlide() 219 - isPointerInsideStrip = false 220 - setControlsVisible(false, animated: false) 221 - waveformView.isLive = false 222 - panel?.orderOut(nil) 223 - } 224 - 225 - func reposition(statusItemButton: NSStatusBarButton?) { 226 - statusButton = statusItemButton 227 - guard let panel = panel, panel.isVisible, !isSliding else { return } 228 - positionPanel(panel) 229 - } 230 - 231 - func refreshAppearance() { 232 - applyAppearanceToVisualizer() 233 - applyWaveformTint() 234 - refreshReadout() 235 - } 236 - 237 - func registerArrowInput() { 238 - guard !suppressed else { return } 239 - showIfNeeded() 240 - refreshReadout() 241 - if menuBand.litNotes.isEmpty { 242 - scheduleHide() 243 - } 244 - } 245 - 246 - private func targetFrame() -> NSRect? { 247 - guard let button = statusButton, 248 - let buttonWindow = button.window else { return nil } 249 - 250 - let imgSize = KeyboardIconRenderer.imageSize 251 - let bb = button.bounds 252 - let xOff = (bb.width - imgSize.width) / 2.0 253 - 254 - let pianoOriginX = xOff + KeyboardIconRenderer.pad 255 - let pianoWidth = imgSize.width - KeyboardIconRenderer.settingsW 256 - - KeyboardIconRenderer.settingsGap - KeyboardIconRenderer.pad * 2 257 - 258 - let localRect = NSRect(x: pianoOriginX, y: 0, width: pianoWidth, height: bb.height) 259 - let windowRect = button.convert(localRect, to: nil) 260 - let screenRect = buttonWindow.convertToScreen(windowRect) 261 - 262 - let stripHeight = currentStripHeight(for: screenRect.width) 263 - let anchoredFrame = NSRect( 264 - x: screenRect.origin.x, 265 - y: screenRect.origin.y - stripHeight, 266 - width: screenRect.width, 267 - height: stripHeight 268 - ) 269 - guard let customOrigin else { return anchoredFrame } 270 - return clampedFrame( 271 - origin: customOrigin, 272 - size: anchoredFrame.size, 273 - preferredScreen: panel?.screen ?? buttonWindow.screen 274 - ) 275 - } 276 - 277 - private func currentWaveformBezelHeight(for width: CGFloat) -> CGFloat { 278 - round(width / Self.waveformAspectRatio) 279 - } 280 - 281 - private func currentStripHeight(for width: CGFloat) -> CGFloat { 282 - currentWaveformBezelHeight(for: width) + Self.footerHeight 283 - } 284 - 285 - private func loadCustomOrigin() -> NSPoint? { 286 - let defaults = UserDefaults.standard 287 - guard defaults.object(forKey: customOriginXKey) != nil, 288 - defaults.object(forKey: customOriginYKey) != nil else { return nil } 289 - return NSPoint( 290 - x: defaults.double(forKey: customOriginXKey), 291 - y: defaults.double(forKey: customOriginYKey) 292 - ) 293 - } 294 - 295 - private func saveCustomOrigin(_ origin: NSPoint) { 296 - let defaults = UserDefaults.standard 297 - defaults.set(origin.x, forKey: customOriginXKey) 298 - defaults.set(origin.y, forKey: customOriginYKey) 299 - } 300 - 301 - private func clampedFrame(origin: NSPoint, size: NSSize, preferredScreen: NSScreen?) -> NSRect { 302 - let visible = visibleFrame(for: origin, preferredScreen: preferredScreen) 303 - let x = min(max(origin.x, visible.minX), visible.maxX - size.width) 304 - let y = min(max(origin.y, visible.minY), visible.maxY - size.height) 305 - return NSRect(origin: NSPoint(x: x, y: y), size: size) 306 - } 307 - 308 - private func visibleFrame(for origin: NSPoint, preferredScreen: NSScreen?) -> NSRect { 309 - if let screen = NSScreen.screens.first(where: { $0.visibleFrame.contains(origin) }) { 310 - return screen.visibleFrame 311 - } 312 - if let preferredScreen { 313 - return preferredScreen.visibleFrame 314 - } 315 - return NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) 316 - } 317 - 318 - private func moveStrip(to origin: NSPoint) { 319 - guard let panel = panel else { return } 320 - let frame = clampedFrame(origin: origin, size: panel.frame.size, preferredScreen: panel.screen) 321 - customOrigin = frame.origin 322 - saveCustomOrigin(frame.origin) 323 - panel.setFrameOrigin(frame.origin) 324 - } 325 - 326 - private func resetCustomPosition() { 327 - customOrigin = nil 328 - let defaults = UserDefaults.standard 329 - defaults.removeObject(forKey: customOriginXKey) 330 - defaults.removeObject(forKey: customOriginYKey) 331 - guard let panel = panel else { return } 332 - positionPanel(panel) 333 - } 334 - 335 - private func show() { 336 - if panel == nil { buildPanel() } 337 - guard let panel = panel, let target = targetFrame() else { return } 338 - 339 - isPointerInsideStrip = false 340 - setControlsVisible(false, animated: false) 341 - waveformBezelHeightConstraint?.constant = currentWaveformBezelHeight(for: target.width) 342 - panel.setFrame(target, display: true) 343 - isSliding = true 344 - panel.alphaValue = 1.0 345 - panel.alphaValue = 0.0 346 - applyAppearanceToVisualizer() 347 - applyWaveformTint() 348 - refreshReadout() 349 - waveformView.isLive = true 350 - panel.orderFrontRegardless() 351 - 352 - NSAnimationContext.runAnimationGroup { context in 353 - context.duration = Self.fadeInDuration 354 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 355 - panel.animator().alphaValue = 1.0 356 - } completionHandler: { [weak self, weak panel] in 357 - guard let self, let panel else { return } 358 - panel.alphaValue = 1.0 359 - self.isSliding = false 360 - } 361 - } 362 - 363 - private func hide(animated: Bool) { 364 - cancelPendingHide() 365 - stopSlide() 366 - guard let panel = panel, panel.isVisible else { 367 - waveformView.isLive = false 368 - return 369 - } 370 - if !animated { 371 - panel.animator().alphaValue = 1.0 372 - waveformView.isLive = false 373 - panel.orderOut(nil) 374 - return 375 - } 376 - NSAnimationContext.runAnimationGroup { context in 377 - context.duration = Self.fadeOutDuration 378 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 379 - panel.animator().alphaValue = 0.0 380 - } completionHandler: { [weak self, weak panel] in 381 - guard let self, let panel else { return } 382 - self.waveformView.isLive = false 383 - panel.orderOut(nil) 384 - panel.alphaValue = 1.0 385 - self.isSliding = false 386 - } 387 - } 388 - 389 - private func startSlide(fromY: CGFloat, toY: CGFloat, completion: @escaping () -> Void) { 390 - stopSlide() 391 - isSliding = true 392 - slideFromY = fromY 393 - slideToY = toY 394 - slideStartTime = CACurrentMediaTime() 395 - slideCompletion = completion 396 - 397 - var link: CVDisplayLink? 398 - CVDisplayLinkCreateWithActiveCGDisplays(&link) 399 - guard let link = link else { 400 - panel?.setFrameOrigin(NSPoint(x: panel?.frame.origin.x ?? 0, y: toY)) 401 - completion() 402 - return 403 - } 404 - let opaque = Unmanaged.passUnretained(self).toOpaque() 405 - CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx -> CVReturn in 406 - guard let ctx = ctx else { return kCVReturnSuccess } 407 - let strip = Unmanaged<LegacyMenuBarWaveformStrip>.fromOpaque(ctx).takeUnretainedValue() 408 - DispatchQueue.main.async { strip.tickSlide() } 409 - return kCVReturnSuccess 410 - }, opaque) 411 - CVDisplayLinkStart(link) 412 - slideLink = link 413 - } 414 - 415 - private func tickSlide() { 416 - guard isSliding, let panel = panel else { 417 - stopSlide() 418 - return 419 - } 420 - let elapsed = CACurrentMediaTime() - slideStartTime 421 - let t = min(1.0, elapsed / Self.fadeInDuration) 422 - let eased: CGFloat 423 - if slideToY < slideFromY { 424 - let f = 1.0 - t 425 - eased = CGFloat(1.0 - f * f * f) 426 - } else { 427 - eased = CGFloat(t * t * t) 428 - } 429 - let currentY = slideFromY + (slideToY - slideFromY) * eased 430 - panel.setFrameOrigin(NSPoint(x: panel.frame.origin.x, y: currentY)) 431 - 432 - if t >= 1.0 { 433 - let completion = slideCompletion 434 - stopSlide() 435 - completion?() 436 - } 437 - } 438 - 439 - private func stopSlide() { 440 - if let link = slideLink { 441 - CVDisplayLinkStop(link) 442 - slideLink = nil 443 - } 444 - slideCompletion = nil 445 - } 446 - 447 - private func cancelPendingHide() { 448 - hideWorkItem?.cancel() 449 - hideWorkItem = nil 450 - } 451 - 452 - private func buildPanel() { 453 - let initialWidth: CGFloat = 200 454 - let p = LegacyMenuBarWaveformStripPanel( 455 - contentRect: NSRect( 456 - origin: .zero, 457 - size: NSSize(width: initialWidth, height: currentStripHeight(for: initialWidth)) 458 - ), 459 - styleMask: [.borderless, .nonactivatingPanel], 460 - backing: .buffered, 461 - defer: false 462 - ) 463 - p.isOpaque = true 464 - p.backgroundColor = .black 465 - p.hasShadow = true 466 - p.level = Self.stripLevel 467 - p.collectionBehavior = [.transient, .ignoresCycle] 468 - p.hidesOnDeactivate = false 469 - p.canHide = false 470 - p.isMovable = false 471 - p.animationBehavior = .none 472 - p.acceptsMouseMovedEvents = false 473 - p.onDragBegan = { [weak self] in 474 - self?.handleStripDragBegan() 475 - } 476 - p.onDragMoved = { [weak self] origin in 477 - self?.moveStrip(to: origin) 478 - } 479 - p.onDragEnded = { [weak self] in 480 - self?.handleStripDragEnded() 481 - } 482 - 483 - waveformView.translatesAutoresizingMaskIntoConstraints = false 484 - waveformBezel.translatesAutoresizingMaskIntoConstraints = false 485 - instrumentArrows.translatesAutoresizingMaskIntoConstraints = false 486 - instrumentRowContainer.translatesAutoresizingMaskIntoConstraints = false 487 - instrumentLabel.translatesAutoresizingMaskIntoConstraints = false 488 - closeButton.translatesAutoresizingMaskIntoConstraints = false 489 - dockButton.translatesAutoresizingMaskIntoConstraints = false 490 - expandButton.translatesAutoresizingMaskIntoConstraints = false 491 - heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 492 - heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 493 - let content = StripInteractiveSurfaceView() 494 - content.onMouseEntered = { [weak self] in self?.handleStripMouseEntered() } 495 - content.onMouseExited = { [weak self] in self?.handleStripMouseExited() } 496 - content.wantsLayer = true 497 - content.layer?.backgroundColor = NSColor.clear.cgColor 498 - p.contentView = content 499 - p.contentView?.addSubview(waveformBezel) 500 - p.contentView?.addSubview(heldNotesContainer) 501 - p.contentView?.addSubview(instrumentRowContainer) 502 - p.contentView?.addSubview(closeButton) 503 - p.contentView?.addSubview(dockButton) 504 - p.contentView?.addSubview(expandButton) 505 - instrumentRowContainer.addSubview(instrumentArrows) 506 - instrumentRowContainer.addSubview(instrumentLabel) 507 - heldNotesContainer.addSubview(heldNotesStack) 508 - waveformBezel.addSubview(waveformView) 509 - let bezelInset: CGFloat = 5 510 - let waveformBezelHeightConstraint = waveformBezel.heightAnchor.constraint( 511 - equalToConstant: currentWaveformBezelHeight(for: initialWidth) 512 - ) 513 - self.waveformBezelHeightConstraint = waveformBezelHeightConstraint 514 - NSLayoutConstraint.activate([ 515 - waveformBezel.leadingAnchor.constraint(equalTo: content.leadingAnchor), 516 - waveformBezel.trailingAnchor.constraint(equalTo: content.trailingAnchor), 517 - waveformBezel.topAnchor.constraint(equalTo: content.topAnchor), 518 - waveformBezelHeightConstraint, 519 - waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 520 - waveformView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 521 - waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 522 - waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 523 - heldNotesContainer.leadingAnchor.constraint(equalTo: content.leadingAnchor), 524 - heldNotesContainer.trailingAnchor.constraint(equalTo: content.trailingAnchor), 525 - heldNotesContainer.topAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: 2), 526 - heldNotesContainer.heightAnchor.constraint(equalToConstant: Self.heldNotesRowHeight), 527 - heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 528 - heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 529 - closeButton.topAnchor.constraint(equalTo: content.topAnchor, constant: 6), 530 - closeButton.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 6), 531 - closeButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 532 - closeButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 533 - expandButton.topAnchor.constraint(equalTo: content.topAnchor, constant: 6), 534 - expandButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -6), 535 - expandButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 536 - expandButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 537 - dockButton.topAnchor.constraint(equalTo: content.topAnchor, constant: 6), 538 - dockButton.trailingAnchor.constraint(equalTo: expandButton.leadingAnchor, constant: -4), 539 - dockButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 540 - dockButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 541 - instrumentRowContainer.leadingAnchor.constraint(equalTo: content.leadingAnchor), 542 - instrumentRowContainer.trailingAnchor.constraint(equalTo: content.trailingAnchor), 543 - instrumentRowContainer.topAnchor.constraint(equalTo: heldNotesContainer.bottomAnchor, constant: 2), 544 - instrumentRowContainer.heightAnchor.constraint(equalToConstant: Self.instrumentRowHeight), 545 - instrumentRowContainer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -2), 546 - instrumentArrows.leadingAnchor.constraint(equalTo: instrumentRowContainer.leadingAnchor, constant: 4), 547 - instrumentArrows.centerYAnchor.constraint(equalTo: instrumentRowContainer.centerYAnchor), 548 - instrumentLabel.leadingAnchor.constraint(equalTo: instrumentArrows.trailingAnchor, constant: 4), 549 - instrumentLabel.centerYAnchor.constraint(equalTo: instrumentRowContainer.centerYAnchor), 550 - instrumentLabel.trailingAnchor.constraint(equalTo: instrumentRowContainer.trailingAnchor, constant: -6), 551 - ]) 552 - applyAppearanceToVisualizer() 553 - applyWaveformTint() 554 - refreshReadout() 555 - setControlsVisible(false, animated: false) 556 - 557 - panel = p 558 - } 559 - 560 - private func positionPanel(_ panel: NSPanel) { 561 - guard let target = targetFrame() else { return } 562 - waveformBezelHeightConstraint?.constant = currentWaveformBezelHeight(for: target.width) 563 - panel.setFrame(target, display: true) 564 - } 565 - 566 - private func applyAppearanceToVisualizer() { 567 - let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 568 - waveformView.setLightMode(!isDark) 569 - if isDark { 570 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 571 - instrumentLabel.textColor = .white 572 - } else { 573 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.82, alpha: 1.0).cgColor 574 - instrumentLabel.textColor = .black 575 - } 576 - } 577 - 578 - private func applyWaveformTint() { 579 - if menuBand.midiMode { 580 - waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 581 - waveformView.setBaseColor(.controlAccentColor) 582 - waveformBezel.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.55).cgColor 583 - } else { 584 - waveformView.setDotMatrix(nil) 585 - let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 586 - let familyColor = InstrumentListView.colorForProgram(safe) 587 - waveformView.setBaseColor(familyColor) 588 - waveformBezel.layer?.borderColor = familyColor.withAlphaComponent(0.55).cgColor 589 - } 590 - for button in [closeButton, dockButton, expandButton] { 591 - button.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.22).cgColor 592 - button.layer?.borderColor = NSColor.white.withAlphaComponent(0.28).cgColor 593 - } 594 - } 595 - 596 - private func refreshReadout() { 597 - let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 598 - let familyColor = menuBand.midiMode 599 - ? NSColor.controlAccentColor 600 - : InstrumentListView.colorForProgram(safe) 601 - let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 602 - let textColor: NSColor = isDark ? .white : .black 603 - let shadow = NSShadow() 604 - shadow.shadowColor = (familyColor.highlight(withLevel: isDark ? 0.3 : 0.7) ?? familyColor) 605 - shadow.shadowOffset = NSSize(width: 1, height: -1) 606 - shadow.shadowBlurRadius = 0 607 - let titleFont: NSFont = { 608 - if let desc = AppDelegate.ywftBoldDescriptor, 609 - let f = NSFont(descriptor: desc, size: 14), 610 - f.familyName == "YWFT Processing" { 611 - return f 612 - } 613 - return NSFont.systemFont(ofSize: 14, weight: .black) 614 - }() 615 - instrumentLabel.attributedStringValue = NSAttributedString( 616 - string: GeneralMIDI.programNames[safe], 617 - attributes: [ 618 - .font: titleFont, 619 - .foregroundColor: textColor, 620 - .shadow: shadow, 621 - ] 622 - ) 623 - for view in heldNotesStack.arrangedSubviews { 624 - heldNotesStack.removeArrangedSubview(view) 625 - view.removeFromSuperview() 626 - } 627 - for name in menuBand.heldNoteNames() { 628 - heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, color: familyColor)) 629 - } 630 - } 631 - 632 - private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 633 - let box = NSView() 634 - box.wantsLayer = true 635 - box.layer?.cornerRadius = 4 636 - box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 637 - box.translatesAutoresizingMaskIntoConstraints = false 638 - let label = NSTextField(labelWithString: name) 639 - label.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .heavy) 640 - label.textColor = .black 641 - label.drawsBackground = false 642 - label.translatesAutoresizingMaskIntoConstraints = false 643 - box.addSubview(label) 644 - NSLayoutConstraint.activate([ 645 - label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 646 - label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 647 - label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 648 - label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 649 - ]) 650 - return box 651 - } 652 - 653 - private func configureControlButton(_ button: NSButton, 654 - symbolName: String, 655 - toolTip: String, 656 - action: Selector) { 657 - let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) 658 - button.image = NSImage( 659 - systemSymbolName: symbolName, 660 - accessibilityDescription: toolTip 661 - )?.withSymbolConfiguration(config) 662 - button.isBordered = false 663 - button.setButtonType(.momentaryChange) 664 - button.imagePosition = .imageOnly 665 - button.contentTintColor = .white.withAlphaComponent(0.92) 666 - button.toolTip = toolTip 667 - button.target = self 668 - button.action = action 669 - button.alphaValue = 0 670 - button.wantsLayer = true 671 - button.layer?.cornerRadius = CGFloat(Self.controlButtonSize) / 2 672 - button.layer?.borderWidth = 1 673 - } 674 - 675 - @objc private func closeButtonClicked(_ sender: NSButton) { 676 - dismiss() 677 - } 678 - 679 - @objc private func dockButtonClicked(_ sender: NSButton) { 680 - resetCustomPosition() 681 - } 682 - 683 - @objc private func expandButtonClicked(_ sender: NSButton) { 684 - onExpandRequested?() 685 - } 686 - 687 - private func setControlsVisible(_ isVisible: Bool, animated: Bool = true) { 688 - let alpha: CGFloat = isVisible ? 1.0 : 0.0 689 - if animated { 690 - NSAnimationContext.runAnimationGroup { context in 691 - context.duration = 0.12 692 - closeButton.animator().alphaValue = alpha 693 - dockButton.animator().alphaValue = alpha 694 - expandButton.animator().alphaValue = alpha 695 - } 696 - } else { 697 - closeButton.alphaValue = alpha 698 - dockButton.alphaValue = alpha 699 - expandButton.alphaValue = alpha 700 - } 701 - } 702 - } 703 - 704 - final class MenuBarWaveformStrip { 705 - private let implementation: any MenuBarWaveformStripImplementation 706 - 707 - var onStepBackward: (() -> Void)? { 708 - get { implementation.onStepBackward } 709 - set { implementation.onStepBackward = newValue } 710 - } 711 - 712 - var onStepForward: (() -> Void)? { 713 - get { implementation.onStepForward } 714 - set { implementation.onStepForward = newValue } 715 - } 716 - 717 - var onStepUp: (() -> Void)? { 718 - get { implementation.onStepUp } 719 - set { implementation.onStepUp = newValue } 720 - } 721 - 722 - var onStepDown: (() -> Void)? { 723 - get { implementation.onStepDown } 724 - set { implementation.onStepDown = newValue } 725 - } 726 - 727 - var onExpandRequested: (() -> Void)? { 728 - get { implementation.onExpandRequested } 729 - set { implementation.onExpandRequested = newValue } 730 - } 731 - 732 - var isDocked: Bool { implementation.isDocked } 733 - 734 - var suppressed: Bool { 735 - get { implementation.suppressed } 736 - set { implementation.suppressed = newValue } 737 - } 738 - 739 - init(menuBand: MenuBandController) { 740 - switch resolvedStripVisualStyle() { 741 - case .liquid: 742 - implementation = LiquidMenuBarWaveformStrip(menuBand: menuBand) 743 - case .legacy: 744 - implementation = LegacyMenuBarWaveformStrip(menuBand: menuBand) 745 - } 746 - } 747 - 748 - func warmUp() { 749 - implementation.warmUp() 750 - } 751 - 752 - func showIfNeeded() { 753 - implementation.showIfNeeded() 754 - } 755 - 756 - func scheduleHide() { 757 - implementation.scheduleHide() 758 - } 759 - 760 - func dismiss() { 761 - implementation.dismiss() 762 - } 763 - 764 - func reposition(statusItemButton: NSStatusBarButton?) { 765 - implementation.reposition(statusItemButton: statusItemButton) 766 - } 767 - 768 - func refreshAppearance() { 769 - implementation.refreshAppearance() 770 - } 771 - 772 - func registerArrowInput() { 773 - implementation.registerArrowInput() 774 - } 775 - } 776 - 777 - private enum ResolvedStripVisualStyle { 778 - case liquid 779 - case legacy 780 - } 781 - 782 - private let stripStyleDefaultsDomain = "computer.aestheticcomputer.menuband" 783 - private let stripStyleDefaultsKey = "MenuBandWaveformStripStyle" 784 - private let stripStyleEnvironmentKey = "MENUBAND_WAVEFORM_STRIP_STYLE" 785 - 786 - private func resolvedStripVisualStyle() -> ResolvedStripVisualStyle { 787 - let environmentValue = ProcessInfo.processInfo.environment[stripStyleEnvironmentKey] 788 - let defaultsValue = UserDefaults(suiteName: stripStyleDefaultsDomain)?.string(forKey: stripStyleDefaultsKey) 789 - ?? UserDefaults.standard.string(forKey: stripStyleDefaultsKey) 790 - let override = StripVisualStyleOverride(rawValue: environmentValue ?? defaultsValue) 791 - switch override { 792 - case .liquid: 793 - if #available(macOS 26.0, *) { 794 - return .liquid 795 - } 796 - return .legacy 797 - case .legacy: 798 - return .legacy 799 - case .automatic: 800 - if #available(macOS 26.0, *) { 801 - return .liquid 802 - } 803 - return .legacy 804 - } 805 - } 806 - 807 - private protocol MenuBarWaveformStripImplementation: AnyObject { 808 - var onStepBackward: (() -> Void)? { get set } 809 - var onStepForward: (() -> Void)? { get set } 810 - var onStepUp: (() -> Void)? { get set } 811 - var onStepDown: (() -> Void)? { get set } 812 - var onExpandRequested: (() -> Void)? { get set } 813 - var isDocked: Bool { get } 814 - var suppressed: Bool { get set } 815 - func warmUp() 816 - func showIfNeeded() 817 - func scheduleHide() 818 - func dismiss() 819 - func reposition(statusItemButton: NSStatusBarButton?) 820 - func refreshAppearance() 821 - func registerArrowInput() 822 - } 823 - 824 - private final class StripDragHandleView: NSView { 825 - var onResetRequested: (() -> Void)? 826 - 827 - private let imageView = NSImageView() 828 - override init(frame frameRect: NSRect) { 829 - super.init(frame: frameRect) 830 - translatesAutoresizingMaskIntoConstraints = false 831 - toolTip = "Double-click to re-dock." 832 - imageView.translatesAutoresizingMaskIntoConstraints = false 833 - imageView.imageScaling = .scaleProportionallyUpOrDown 834 - imageView.image = NSImage( 835 - systemSymbolName: "move.3d", 836 - accessibilityDescription: "Drag strip" 837 - )?.withSymbolConfiguration(.init(pointSize: 10, weight: .semibold)) 838 - addSubview(imageView) 839 - NSLayoutConstraint.activate([ 840 - widthAnchor.constraint(equalToConstant: 14), 841 - heightAnchor.constraint(equalToConstant: 14), 842 - imageView.centerXAnchor.constraint(equalTo: centerXAnchor), 843 - imageView.centerYAnchor.constraint(equalTo: centerYAnchor), 844 - ]) 845 - } 846 - 847 - required init?(coder: NSCoder) { fatalError() } 848 - 849 - override func viewDidChangeEffectiveAppearance() { 850 - super.viewDidChangeEffectiveAppearance() 851 - imageView.contentTintColor = .secondaryLabelColor 852 - } 853 - 854 - override func mouseDown(with event: NSEvent) { 855 - if event.clickCount == 2 { 856 - onResetRequested?() 857 - } 858 - } 859 - } 860 - 861 - private class StripInteractiveSurfaceView: NSView { 862 - var onMouseEntered: (() -> Void)? 863 - var onMouseExited: (() -> Void)? 864 - private var trackingArea: NSTrackingArea? 865 - 866 - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 867 - 868 - override func updateTrackingAreas() { 869 - if let trackingArea { 870 - removeTrackingArea(trackingArea) 871 - } 872 - let trackingArea = NSTrackingArea( 873 - rect: bounds, 874 - options: [.activeAlways, .inVisibleRect, .mouseEnteredAndExited], 875 - owner: self, 876 - userInfo: nil 877 - ) 878 - addTrackingArea(trackingArea) 879 - self.trackingArea = trackingArea 880 - super.updateTrackingAreas() 881 - } 882 - 883 - override func mouseEntered(with event: NSEvent) { 884 - onMouseEntered?() 885 - super.mouseEntered(with: event) 886 - } 887 - 888 - override func mouseExited(with event: NSEvent) { 889 - onMouseExited?() 890 - super.mouseExited(with: event) 891 - } 892 - } 893 - 894 - private final class MenuBarWaveformStripPanel: NSPanel { 895 - var onDragBegan: (() -> Void)? 896 - var onDragEnded: (() -> Void)? 897 - var onFrameChanged: ((NSRect) -> Void)? 898 - var passthroughViews: [NSView] = [] 899 - 900 - override var canBecomeKey: Bool { true } 901 - override var canBecomeMain: Bool { false } 902 - 903 - override func setFrame(_ frameRect: NSRect, display flag: Bool) { 904 - super.setFrame(frameRect, display: flag) 905 - onFrameChanged?(frame) 906 - } 907 - 908 - override func setFrameOrigin(_ point: NSPoint) { 909 - super.setFrameOrigin(point) 910 - onFrameChanged?(frame) 911 - } 912 - 913 - override func sendEvent(_ event: NSEvent) { 914 - if event.type == .leftMouseDown, 915 - event.clickCount == 1, 916 - shouldBeginDrag(for: event) { 917 - onDragBegan?() 918 - performDrag(with: event) 919 - onDragEnded?() 920 - return 921 - } 922 - super.sendEvent(event) 923 - } 924 - 925 - private func shouldBeginDrag(for event: NSEvent) -> Bool { 926 - guard let contentView else { return true } 927 - let point = contentView.convert(event.locationInWindow, from: nil) 928 - guard let hitView = contentView.hitTest(point) else { return true } 929 - return !passthroughViews.contains(where: { hitView === $0 || hitView.isDescendant(of: $0) }) 930 - } 931 - } 932 - 933 - @available(macOS 26.0, *) 934 - private final class StripMovableGlassEffectView: NSGlassEffectView { 935 - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 936 - } 937 - 938 - /// Thin floating waveform strip that slides down from beneath the menubar 939 - /// piano when notes are played, and slides back up after a configurable 940 - /// idle delay. Hidden whenever the settings popover or floating piano 941 - /// palette is visible to avoid visual clutter. 942 - /// 943 - /// The panel sits one level below `NSWindow.Level.mainMenu` so the menubar 944 - /// itself occludes it. The slide animation moves the panel from behind the 945 - /// menubar downward into view, and reverses to hide. 946 - private final class LiquidMenuBarWaveformStrip: NSObject, MenuBarWaveformStripImplementation { 947 - private let menuBand: MenuBandController 948 - private let waveformView = WaveformView() 949 - private let waveformBezel = StripInteractiveSurfaceView() 950 - private let waveformClipView = StripInteractiveSurfaceView() 951 - private var waveformBezelHeightConstraint: NSLayoutConstraint? 952 - private let instrumentArrows = ArrowKeysIndicator() 953 - private let instrumentRowContainer = StripInteractiveSurfaceView() 954 - private let instrumentLabel = NSTextField(labelWithString: "") 955 - private let closeButton = NSButton() 956 - private let dockButton = NSButton() 957 - private let expandButton = NSButton() 958 - private let heldNotesContainer = StripInteractiveSurfaceView() 959 - private let heldNotesStack = NSStackView() 960 - private weak var closeButtonGlassView: NSView? 961 - private weak var dockButtonGlassView: NSView? 962 - private weak var expandButtonGlassView: NSView? 963 - private weak var waveformGlassView: NSView? 964 - private weak var stripBackgroundView: NSView? 965 - private var panel: NSPanel? 966 - private weak var statusButton: NSStatusBarButton? 967 - var onStepBackward: (() -> Void)? 968 - var onStepForward: (() -> Void)? 969 - var onStepUp: (() -> Void)? 970 - var onStepDown: (() -> Void)? 971 - var onExpandRequested: (() -> Void)? 972 - 973 - /// How long to keep the strip visible after the last note ends. 974 - private let hideDelay: TimeInterval = 2.0 975 - private var hideWorkItem: DispatchWorkItem? 976 - private let customOriginXKey = "notepat.waveformStrip.customOriginX" 977 - private let customOriginYKey = "notepat.waveformStrip.customOriginY" 978 - private var customOrigin: NSPoint? 979 - 980 - /// Match the popover waveform bezel's width:height ratio (224:64) so the 981 - /// meter reads consistently in both places. 982 - private static let waveformAspectRatio: CGFloat = InstrumentListView.preferredWidth / 64 983 - private static let waveformGlassCornerRadius: CGFloat = 14 984 - private static let waveformClipCornerRadius: CGFloat = 12 985 - private static let controlButtonSize: CGFloat = 24 986 - private static let controlInset: CGFloat = 8 987 - private static let waveformOuterInset: CGFloat = 4 988 - private static let waveformInnerInset: CGFloat = 2 989 - private static let footerHeight: CGFloat = 54 990 - private static let heldNotesRowHeight: CGFloat = 14 991 - private static let instrumentRowHeight: CGFloat = 30 992 - private static let hitTestBackdropAlpha: CGFloat = 0.001 993 - private static let defaultsDomain = "computer.aestheticcomputer.menuband" 994 - private static let styleOverrideDefaultsKey = "MenuBandWaveformStripStyle" 995 - private static let styleOverrideEnvironmentKey = "MENUBAND_WAVEFORM_STRIP_STYLE" 996 - 997 - /// Animation duration in seconds. 998 - private static let fadeInDuration: TimeInterval = 0.18 999 - private static let fadeOutDuration: TimeInterval = 0.18 1000 - 1001 - /// Window level just below the menubar so the menubar occludes the 1002 - /// strip while it's tucked behind. 1003 - private static let stripLevel = NSWindow.Level.floating 1004 - 1005 - private static var visualStyleOverride: StripVisualStyleOverride { 1006 - let environmentValue = ProcessInfo.processInfo.environment[styleOverrideEnvironmentKey] 1007 - if environmentValue != nil { 1008 - return StripVisualStyleOverride(rawValue: environmentValue) 1009 - } 1010 - let defaultsValue = UserDefaults(suiteName: defaultsDomain)?.string(forKey: styleOverrideDefaultsKey) 1011 - ?? UserDefaults.standard.string(forKey: styleOverrideDefaultsKey) 1012 - return StripVisualStyleOverride(rawValue: defaultsValue) 1013 - } 1014 - 1015 - private static var shouldUseLiquidGlass: Bool { 1016 - switch visualStyleOverride { 1017 - case .liquid: 1018 - if #available(macOS 26.0, *) { 1019 - return true 1020 - } 1021 - return false 1022 - case .legacy: 1023 - return false 1024 - case .automatic: 1025 - if #available(macOS 26.0, *) { 1026 - return true 1027 - } 1028 - return false 1029 - } 1030 - } 1031 - 1032 - /// CVDisplayLink-driven slide animation. NSAnimationContext + animator() 1033 - /// proxy doesn't reliably move borderless non-activating panels, so we 1034 - /// drive the frame ourselves at display refresh rate. 1035 - private var slideLink: CVDisplayLink? 1036 - private var slideStartTime: CFTimeInterval = 0 1037 - private var slideFromY: CGFloat = 0 1038 - private var slideToY: CGFloat = 0 1039 - private var slideCompletion: (() -> Void)? 1040 - private var isSliding = false 1041 - private var isPointerInsideStrip = false 1042 - private var isDraggingStrip = false 1043 - 1044 - /// True while the strip panel is on screen (visible or animating in). 1045 - var isShown: Bool { panel?.isVisible == true } 1046 - var isDocked: Bool { customOrigin == nil } 1047 - 1048 - /// External suppression — callers set this when the popover or floating 1049 - /// palette opens. While suppressed, `showIfNeeded` is a no-op and any 1050 - /// visible strip is immediately hidden. 1051 - var suppressed: Bool = false { 1052 - didSet { 1053 - guard suppressed != oldValue else { return } 1054 - if suppressed { hide(animated: false) } 1055 - } 1056 - } 1057 - 1058 - init(menuBand: MenuBandController) { 1059 - self.menuBand = menuBand 1060 - super.init() 1061 - customOrigin = loadCustomOrigin() 1062 - waveformView.menuBand = menuBand 1063 - waveformView.setSurfaceStyle(.glassEmbedded) 1064 - waveformBezel.wantsLayer = true 1065 - waveformBezel.layer?.cornerRadius = Self.waveformGlassCornerRadius 1066 - waveformBezel.layer?.borderWidth = 0.8 1067 - if #available(macOS 10.15, *) { 1068 - waveformBezel.layer?.cornerCurve = .continuous 1069 - } 1070 - waveformClipView.wantsLayer = true 1071 - waveformClipView.layer?.cornerRadius = Self.waveformClipCornerRadius 1072 - waveformClipView.layer?.masksToBounds = true 1073 - if #available(macOS 10.15, *) { 1074 - waveformClipView.layer?.cornerCurve = .continuous 1075 - } 1076 - heldNotesStack.orientation = .horizontal 1077 - heldNotesStack.alignment = .centerY 1078 - heldNotesStack.spacing = 4 1079 - instrumentLabel.drawsBackground = false 1080 - instrumentLabel.lineBreakMode = .byTruncatingTail 1081 - instrumentArrows.displayMode = .cluster 1082 - instrumentArrows.toolTip = "Change instrument or octave" 1083 - instrumentArrows.onClick = { [weak self] dir, isDown in 1084 - guard isDown else { return } 1085 - self?.registerArrowInput() 1086 - switch dir { 1087 - case 0: self?.onStepBackward?() 1088 - case 1: self?.onStepForward?() 1089 - case 2: self?.onStepDown?() 1090 - case 3: self?.onStepUp?() 1091 - default: break 1092 - } 1093 - } 1094 - configureControlButton( 1095 - closeButton, 1096 - symbolName: "xmark", 1097 - toolTip: "Close", 1098 - action: #selector(closeButtonClicked(_:)) 1099 - ) 1100 - configureControlButton( 1101 - dockButton, 1102 - symbolName: "menubar.dock.rectangle", 1103 - toolTip: "Dock Below Menubar Piano", 1104 - action: #selector(dockButtonClicked(_:)) 1105 - ) 1106 - configureControlButton( 1107 - expandButton, 1108 - symbolName: "square.resize.up", 1109 - toolTip: "Expand floating piano", 1110 - action: #selector(expandButtonClicked(_:)) 1111 - ) 1112 - } 1113 - 1114 - private func handleStripDragBegan() { 1115 - isDraggingStrip = true 1116 - cancelPendingHide() 1117 - stopSlide() 1118 - isSliding = false 1119 - } 1120 - 1121 - private func handleStripDragEnded() { 1122 - isDraggingStrip = false 1123 - persistPanelOriginIfNeeded(force: true) 1124 - refreshAutoHideState() 1125 - } 1126 - 1127 - private func persistPanelOriginIfNeeded(force: Bool = false) { 1128 - guard let panel, force || isDraggingStrip else { return } 1129 - let frame = clampedFrame(origin: panel.frame.origin, size: panel.frame.size, preferredScreen: panel.screen) 1130 - customOrigin = frame.origin 1131 - saveCustomOrigin(frame.origin) 1132 - if panel.frame.origin != frame.origin { 1133 - panel.setFrameOrigin(frame.origin) 1134 - } 1135 - } 1136 - 1137 - private func handleStripMouseEntered() { 1138 - guard !isPointerInsideStrip else { return } 1139 - isPointerInsideStrip = true 1140 - setControlsVisible(true) 1141 - cancelPendingHide() 1142 - } 1143 - 1144 - private func handleStripMouseExited() { 1145 - guard isPointerInsideStrip else { return } 1146 - isPointerInsideStrip = false 1147 - setControlsVisible(false) 1148 - refreshAutoHideState() 1149 - } 1150 - 1151 - private func refreshAutoHideState() { 1152 - guard menuBand.litNotes.isEmpty else { 1153 - cancelPendingHide() 1154 - return 1155 - } 1156 - scheduleHide() 1157 - } 1158 - 1159 - /// Pre-build the panel so the first note doesn't pay construction cost. 1160 - func warmUp() { 1161 - if panel == nil { buildPanel() } 1162 - } 1163 - 1164 - deinit { 1165 - stopSlide() 1166 - } 1167 - 1168 - // MARK: - Show / hide 1169 - 1170 - /// Call whenever `litNotes` becomes non-empty (a note starts). 1171 - func showIfNeeded() { 1172 - guard !suppressed else { return } 1173 - cancelPendingHide() 1174 - if !isShown && !isSliding { show() } 1175 - } 1176 - 1177 - /// Call whenever `litNotes` becomes empty (all notes released). 1178 - func scheduleHide() { 1179 - guard !isPointerInsideStrip, !isDraggingStrip else { 1180 - cancelPendingHide() 1181 - return 1182 - } 1183 - cancelPendingHide() 1184 - let work = DispatchWorkItem { [weak self] in 1185 - self?.hide(animated: true) 1186 - } 1187 - hideWorkItem = work 1188 - DispatchQueue.main.asyncAfter(deadline: .now() + hideDelay, execute: work) 1189 - } 1190 - 1191 - /// Immediately tear down (app termination, etc.). 1192 - func dismiss() { 1193 - cancelPendingHide() 1194 - stopSlide() 1195 - isPointerInsideStrip = false 1196 - setControlsVisible(false, animated: false) 1197 - waveformView.isLive = false 1198 - panel?.orderOut(nil) 1199 - } 1200 - 1201 - /// Store the button reference so positioning works from inside show(). 1202 - func reposition(statusItemButton: NSStatusBarButton?) { 1203 - statusButton = statusItemButton 1204 - guard let panel = panel, panel.isVisible, !isSliding else { return } 1205 - guard isDocked else { return } 1206 - positionPanel(panel) 1207 - } 1208 - 1209 - func refreshAppearance() { 1210 - applyAppearanceToVisualizer() 1211 - applyWaveformTint() 1212 - refreshReadout() 1213 - } 1214 - 1215 - func registerArrowInput() { 1216 - guard !suppressed else { return } 1217 - showIfNeeded() 1218 - refreshReadout() 1219 - if menuBand.litNotes.isEmpty { 1220 - scheduleHide() 1221 - } 1222 - } 1223 - 1224 - // MARK: - Geometry 1225 - 1226 - /// Compute the target frame for the strip: directly below the menubar, 1227 - /// aligned to the piano key region of the status item. 1228 - private func targetFrame() -> NSRect? { 1229 - guard let button = statusButton, 1230 - let buttonWindow = button.window else { return nil } 1231 - 1232 - let imgSize = KeyboardIconRenderer.imageSize 1233 - let bb = button.bounds 1234 - let xOff = (bb.width - imgSize.width) / 2.0 1235 - 1236 - let pianoOriginX = xOff + KeyboardIconRenderer.pad 1237 - let pianoWidth = imgSize.width - KeyboardIconRenderer.settingsW 1238 - - KeyboardIconRenderer.settingsGap - KeyboardIconRenderer.pad * 2 1239 - 1240 - let localRect = NSRect(x: pianoOriginX, y: 0, width: pianoWidth, height: bb.height) 1241 - let windowRect = button.convert(localRect, to: nil) 1242 - let screenRect = buttonWindow.convertToScreen(windowRect) 1243 - 1244 - let stripHeight = currentStripHeight(for: screenRect.width) 1245 - let anchoredFrame = NSRect( 1246 - x: screenRect.origin.x, 1247 - y: screenRect.origin.y - stripHeight, 1248 - width: screenRect.width, 1249 - height: stripHeight 1250 - ) 1251 - guard let customOrigin else { return anchoredFrame } 1252 - return clampedFrame( 1253 - origin: customOrigin, 1254 - size: anchoredFrame.size, 1255 - preferredScreen: panel?.screen ?? buttonWindow.screen 1256 - ) 1257 - } 1258 - 1259 - private func currentWaveformBezelHeight(for width: CGFloat) -> CGFloat { 1260 - round(width / Self.waveformAspectRatio) 1261 - } 1262 - 1263 - private func currentStripHeight(for width: CGFloat) -> CGFloat { 1264 - currentWaveformBezelHeight(for: width) + Self.footerHeight 1265 - } 1266 - 1267 - private func loadCustomOrigin() -> NSPoint? { 1268 - let defaults = UserDefaults.standard 1269 - guard defaults.object(forKey: customOriginXKey) != nil, 1270 - defaults.object(forKey: customOriginYKey) != nil else { return nil } 1271 - return NSPoint( 1272 - x: defaults.double(forKey: customOriginXKey), 1273 - y: defaults.double(forKey: customOriginYKey) 1274 - ) 1275 - } 1276 - 1277 - private func saveCustomOrigin(_ origin: NSPoint) { 1278 - let defaults = UserDefaults.standard 1279 - defaults.set(origin.x, forKey: customOriginXKey) 1280 - defaults.set(origin.y, forKey: customOriginYKey) 1281 - } 1282 - 1283 - private func clampedFrame(origin: NSPoint, size: NSSize, preferredScreen: NSScreen?) -> NSRect { 1284 - let visible = visibleFrame(for: origin, preferredScreen: preferredScreen) 1285 - let x = min(max(origin.x, visible.minX), visible.maxX - size.width) 1286 - let y = min(max(origin.y, visible.minY), visible.maxY - size.height) 1287 - return NSRect(origin: NSPoint(x: x, y: y), size: size) 1288 - } 1289 - 1290 - private func visibleFrame(for origin: NSPoint, preferredScreen: NSScreen?) -> NSRect { 1291 - if let screen = NSScreen.screens.first(where: { $0.visibleFrame.contains(origin) }) { 1292 - return screen.visibleFrame 1293 - } 1294 - if let preferredScreen { 1295 - return preferredScreen.visibleFrame 1296 - } 1297 - return NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) 1298 - } 1299 - 1300 - private func moveStrip(to origin: NSPoint) { 1301 - guard let panel = panel else { return } 1302 - let frame = clampedFrame(origin: origin, size: panel.frame.size, preferredScreen: panel.screen) 1303 - customOrigin = frame.origin 1304 - saveCustomOrigin(frame.origin) 1305 - panel.setFrameOrigin(frame.origin) 1306 - } 1307 - 1308 - private func resetCustomPosition() { 1309 - customOrigin = nil 1310 - let defaults = UserDefaults.standard 1311 - defaults.removeObject(forKey: customOriginXKey) 1312 - defaults.removeObject(forKey: customOriginYKey) 1313 - guard let panel = panel else { return } 1314 - positionPanel(panel) 1315 - } 1316 - 1317 - // MARK: - Visibility animation 1318 - 1319 - private func show() { 1320 - if panel == nil { buildPanel() } 1321 - guard let panel = panel, let target = targetFrame() else { return } 1322 - 1323 - isPointerInsideStrip = false 1324 - setControlsVisible(false, animated: false) 1325 - waveformBezelHeightConstraint?.constant = currentWaveformBezelHeight(for: target.width) 1326 - panel.setFrame(target, display: true) 1327 - isSliding = true 1328 - panel.alphaValue = 1.0 1329 - panel.alphaValue = 0.0 1330 - applyAppearanceToVisualizer() 1331 - applyWaveformTint() 1332 - refreshReadout() 1333 - waveformView.isLive = true 1334 - panel.orderFrontRegardless() 1335 - 1336 - NSAnimationContext.runAnimationGroup { context in 1337 - context.duration = Self.fadeInDuration 1338 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 1339 - panel.animator().alphaValue = 1.0 1340 - } completionHandler: { [weak self, weak panel] in 1341 - guard let self, let panel else { return } 1342 - panel.alphaValue = 1.0 1343 - self.isSliding = false 1344 - } 1345 - } 1346 - 1347 - private func hide(animated: Bool) { 1348 - cancelPendingHide() 1349 - stopSlide() 1350 - guard !isPointerInsideStrip, !isDraggingStrip else { return } 1351 - guard let panel = panel, panel.isVisible else { 1352 - waveformView.isLive = false 1353 - return 1354 - } 1355 - if !animated { 1356 - panel.animator().alphaValue = 1.0 1357 - waveformView.isLive = false 1358 - panel.orderOut(nil) 1359 - return 1360 - } 1361 - NSAnimationContext.runAnimationGroup { context in 1362 - context.duration = Self.fadeOutDuration 1363 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 1364 - panel.animator().alphaValue = 0.0 1365 - } completionHandler: { [weak self, weak panel] in 1366 - guard let self, let panel else { return } 1367 - self.waveformView.isLive = false 1368 - panel.orderOut(nil) 1369 - panel.alphaValue = 1.0 1370 - self.isSliding = false 1371 - } 1372 - } 1373 - 1374 - private func startSlide(fromY: CGFloat, toY: CGFloat, completion: @escaping () -> Void) { 1375 - stopSlide() 1376 - isSliding = true 1377 - slideFromY = fromY 1378 - slideToY = toY 1379 - slideStartTime = CACurrentMediaTime() 1380 - slideCompletion = completion 1381 - 1382 - var link: CVDisplayLink? 1383 - CVDisplayLinkCreateWithActiveCGDisplays(&link) 1384 - guard let link = link else { 1385 - // Fallback: jump immediately if CVDisplayLink fails. 1386 - panel?.setFrameOrigin(NSPoint(x: panel?.frame.origin.x ?? 0, y: toY)) 1387 - completion() 1388 - return 1389 - } 1390 - let opaque = Unmanaged.passUnretained(self).toOpaque() 1391 - CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx -> CVReturn in 1392 - guard let ctx = ctx else { return kCVReturnSuccess } 1393 - let strip = Unmanaged<LiquidMenuBarWaveformStrip>.fromOpaque(ctx).takeUnretainedValue() 1394 - DispatchQueue.main.async { strip.tickSlide() } 1395 - return kCVReturnSuccess 1396 - }, opaque) 1397 - CVDisplayLinkStart(link) 1398 - slideLink = link 1399 - } 1400 - 1401 - private func tickSlide() { 1402 - guard isSliding, let panel = panel else { 1403 - stopSlide() 1404 - return 1405 - } 1406 - let elapsed = CACurrentMediaTime() - slideStartTime 1407 - let t = min(1.0, elapsed / Self.fadeInDuration) 1408 - // Ease-out cubic for show, ease-in cubic for hide. 1409 - let eased: CGFloat 1410 - if slideToY < slideFromY { 1411 - // Sliding down (show): ease-out 1412 - let f = 1.0 - t 1413 - eased = CGFloat(1.0 - f * f * f) 1414 - } else { 1415 - // Sliding up (hide): ease-in 1416 - eased = CGFloat(t * t * t) 1417 - } 1418 - let currentY = slideFromY + (slideToY - slideFromY) * eased 1419 - panel.setFrameOrigin(NSPoint(x: panel.frame.origin.x, y: currentY)) 1420 - 1421 - if t >= 1.0 { 1422 - let completion = slideCompletion 1423 - stopSlide() 1424 - completion?() 1425 - } 1426 - } 1427 - 1428 - private func stopSlide() { 1429 - if let link = slideLink { 1430 - CVDisplayLinkStop(link) 1431 - slideLink = nil 1432 - } 1433 - slideCompletion = nil 1434 - } 1435 - 1436 - private func cancelPendingHide() { 1437 - hideWorkItem?.cancel() 1438 - hideWorkItem = nil 1439 - } 1440 - 1441 - // MARK: - Panel construction 1442 - 1443 - private func buildPanel() { 1444 - let initialWidth: CGFloat = 200 1445 - let p = MenuBarWaveformStripPanel( 1446 - contentRect: NSRect( 1447 - origin: .zero, 1448 - size: NSSize(width: initialWidth, height: currentStripHeight(for: initialWidth)) 1449 - ), 1450 - styleMask: [.borderless], 1451 - backing: .buffered, 1452 - defer: false 1453 - ) 1454 - p.isOpaque = false 1455 - p.backgroundColor = .clear 1456 - p.hasShadow = true 1457 - p.level = Self.stripLevel 1458 - p.collectionBehavior = [.transient, .ignoresCycle] 1459 - p.hidesOnDeactivate = false 1460 - p.canHide = false 1461 - p.isMovable = true 1462 - p.isMovableByWindowBackground = false 1463 - p.animationBehavior = .none 1464 - p.acceptsMouseMovedEvents = false 1465 - p.onDragBegan = { [weak self] in self?.handleStripDragBegan() } 1466 - p.onDragEnded = { [weak self] in self?.handleStripDragEnded() } 1467 - p.onFrameChanged = { [weak self] _ in 1468 - self?.persistPanelOriginIfNeeded() 1469 - } 1470 - p.passthroughViews = [instrumentArrows, closeButton, dockButton, expandButton] 1471 - waveformView.translatesAutoresizingMaskIntoConstraints = false 1472 - waveformBezel.translatesAutoresizingMaskIntoConstraints = false 1473 - waveformClipView.translatesAutoresizingMaskIntoConstraints = false 1474 - instrumentArrows.translatesAutoresizingMaskIntoConstraints = false 1475 - instrumentRowContainer.translatesAutoresizingMaskIntoConstraints = false 1476 - instrumentLabel.translatesAutoresizingMaskIntoConstraints = false 1477 - closeButton.translatesAutoresizingMaskIntoConstraints = false 1478 - dockButton.translatesAutoresizingMaskIntoConstraints = false 1479 - expandButton.translatesAutoresizingMaskIntoConstraints = false 1480 - heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 1481 - heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 1482 - let rootView = StripInteractiveSurfaceView() 1483 - rootView.translatesAutoresizingMaskIntoConstraints = false 1484 - rootView.onMouseEntered = { [weak self] in self?.handleStripMouseEntered() } 1485 - rootView.onMouseExited = { [weak self] in self?.handleStripMouseExited() } 1486 - rootView.wantsLayer = true 1487 - // Keep the panel visually transparent while ensuring WindowServer 1488 - // still treats it as a hittable surface instead of passing clicks 1489 - // through to the app underneath. 1490 - rootView.layer?.backgroundColor = NSColor.black.withAlphaComponent(Self.hitTestBackdropAlpha).cgColor 1491 - let layoutRoot: NSView 1492 - if #available(macOS 26.0, *) { 1493 - let glassContainer = NSGlassEffectContainerView() 1494 - glassContainer.translatesAutoresizingMaskIntoConstraints = false 1495 - glassContainer.spacing = 18 1496 - glassContainer.contentView = rootView 1497 - p.contentView = glassContainer 1498 - 1499 - let stripGlassView = StripMovableGlassEffectView() 1500 - stripGlassView.translatesAutoresizingMaskIntoConstraints = false 1501 - stripGlassView.cornerRadius = 14 1502 - let stripContentView = StripInteractiveSurfaceView() 1503 - stripContentView.translatesAutoresizingMaskIntoConstraints = false 1504 - stripGlassView.contentView = stripContentView 1505 - rootView.addSubview(stripGlassView) 1506 - NSLayoutConstraint.activate([ 1507 - stripGlassView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), 1508 - stripGlassView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), 1509 - stripGlassView.topAnchor.constraint(equalTo: rootView.topAnchor), 1510 - stripGlassView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), 1511 - ]) 1512 - 1513 - stripBackgroundView = stripGlassView 1514 - layoutRoot = stripContentView 1515 - 1516 - let waveformGlassView = StripMovableGlassEffectView() 1517 - waveformGlassView.translatesAutoresizingMaskIntoConstraints = false 1518 - waveformGlassView.cornerRadius = Self.waveformGlassCornerRadius 1519 - let waveformContentView = StripInteractiveSurfaceView() 1520 - waveformContentView.translatesAutoresizingMaskIntoConstraints = false 1521 - waveformGlassView.contentView = waveformContentView 1522 - layoutRoot.addSubview(waveformBezel) 1523 - waveformBezel.addSubview(waveformGlassView) 1524 - self.waveformGlassView = waveformGlassView 1525 - waveformContentView.addSubview(waveformClipView) 1526 - waveformClipView.addSubview(waveformView) 1527 - NSLayoutConstraint.activate([ 1528 - waveformBezel.leadingAnchor.constraint(equalTo: layoutRoot.leadingAnchor, constant: Self.waveformOuterInset), 1529 - waveformBezel.trailingAnchor.constraint(equalTo: layoutRoot.trailingAnchor, constant: -Self.waveformOuterInset), 1530 - waveformBezel.topAnchor.constraint(equalTo: layoutRoot.topAnchor, constant: Self.waveformOuterInset), 1531 - waveformGlassView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 1532 - waveformGlassView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 1533 - waveformGlassView.topAnchor.constraint(equalTo: waveformBezel.topAnchor), 1534 - waveformGlassView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor), 1535 - waveformClipView.leadingAnchor.constraint(equalTo: waveformContentView.leadingAnchor, constant: Self.waveformInnerInset), 1536 - waveformClipView.trailingAnchor.constraint(equalTo: waveformContentView.trailingAnchor, constant: -Self.waveformInnerInset), 1537 - waveformClipView.topAnchor.constraint(equalTo: waveformContentView.topAnchor, constant: Self.waveformInnerInset), 1538 - waveformClipView.bottomAnchor.constraint(equalTo: waveformContentView.bottomAnchor, constant: -Self.waveformInnerInset), 1539 - waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 1540 - waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 1541 - waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 1542 - waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 1543 - ]) 1544 - } else { 1545 - p.contentView = rootView 1546 - stripBackgroundView = rootView 1547 - rootView.addSubview(waveformBezel) 1548 - waveformBezel.addSubview(waveformClipView) 1549 - waveformClipView.addSubview(waveformView) 1550 - NSLayoutConstraint.activate([ 1551 - waveformBezel.leadingAnchor.constraint(equalTo: rootView.leadingAnchor, constant: Self.waveformOuterInset), 1552 - waveformBezel.trailingAnchor.constraint(equalTo: rootView.trailingAnchor, constant: -Self.waveformOuterInset), 1553 - waveformBezel.topAnchor.constraint(equalTo: rootView.topAnchor, constant: Self.waveformOuterInset), 1554 - waveformClipView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: Self.waveformInnerInset), 1555 - waveformClipView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -Self.waveformInnerInset), 1556 - waveformClipView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: Self.waveformInnerInset), 1557 - waveformClipView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -Self.waveformInnerInset), 1558 - waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 1559 - waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 1560 - waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 1561 - waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 1562 - ]) 1563 - layoutRoot = rootView 1564 - waveformGlassView = waveformBezel 1565 - } 1566 - layoutRoot.addSubview(heldNotesContainer) 1567 - layoutRoot.addSubview(instrumentRowContainer) 1568 - rootView.addSubview(closeButton) 1569 - rootView.addSubview(dockButton) 1570 - rootView.addSubview(expandButton) 1571 - if #available(macOS 26.0, *) { 1572 - closeButtonGlassView = installControlGlassBackground(for: closeButton, in: rootView) 1573 - dockButtonGlassView = installControlGlassBackground(for: dockButton, in: rootView) 1574 - expandButtonGlassView = installControlGlassBackground(for: expandButton, in: rootView) 1575 - } 1576 - instrumentRowContainer.addSubview(instrumentArrows) 1577 - instrumentRowContainer.addSubview(instrumentLabel) 1578 - heldNotesContainer.addSubview(heldNotesStack) 1579 - let waveformBezelHeightConstraint = waveformBezel.heightAnchor.constraint( 1580 - equalToConstant: currentWaveformBezelHeight(for: initialWidth) 1581 - ) 1582 - self.waveformBezelHeightConstraint = waveformBezelHeightConstraint 1583 - NSLayoutConstraint.activate([ 1584 - waveformBezelHeightConstraint, 1585 - heldNotesContainer.leadingAnchor.constraint(equalTo: layoutRoot.leadingAnchor, constant: 4), 1586 - heldNotesContainer.trailingAnchor.constraint(equalTo: layoutRoot.trailingAnchor, constant: -4), 1587 - heldNotesContainer.topAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: 3), 1588 - heldNotesContainer.heightAnchor.constraint(equalToConstant: Self.heldNotesRowHeight), 1589 - heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 1590 - heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 1591 - closeButton.topAnchor.constraint(equalTo: rootView.topAnchor, constant: Self.controlInset), 1592 - closeButton.leadingAnchor.constraint(equalTo: rootView.leadingAnchor, constant: Self.controlInset), 1593 - closeButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 1594 - closeButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 1595 - expandButton.topAnchor.constraint(equalTo: rootView.topAnchor, constant: Self.controlInset), 1596 - expandButton.trailingAnchor.constraint(equalTo: rootView.trailingAnchor, constant: -Self.controlInset), 1597 - expandButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 1598 - expandButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 1599 - dockButton.topAnchor.constraint(equalTo: rootView.topAnchor, constant: Self.controlInset), 1600 - dockButton.trailingAnchor.constraint(equalTo: expandButton.leadingAnchor, constant: -6), 1601 - dockButton.widthAnchor.constraint(equalToConstant: Self.controlButtonSize), 1602 - dockButton.heightAnchor.constraint(equalToConstant: Self.controlButtonSize), 1603 - instrumentRowContainer.leadingAnchor.constraint(equalTo: layoutRoot.leadingAnchor, constant: 4), 1604 - instrumentRowContainer.trailingAnchor.constraint(equalTo: layoutRoot.trailingAnchor, constant: -4), 1605 - instrumentRowContainer.topAnchor.constraint(equalTo: heldNotesContainer.bottomAnchor, constant: 3), 1606 - instrumentRowContainer.heightAnchor.constraint(equalToConstant: Self.instrumentRowHeight), 1607 - instrumentRowContainer.bottomAnchor.constraint(equalTo: layoutRoot.bottomAnchor, constant: -4), 1608 - instrumentArrows.leadingAnchor.constraint(equalTo: instrumentRowContainer.leadingAnchor, constant: 6), 1609 - instrumentArrows.centerYAnchor.constraint(equalTo: instrumentRowContainer.centerYAnchor), 1610 - instrumentLabel.leadingAnchor.constraint(equalTo: instrumentArrows.trailingAnchor, constant: 4), 1611 - instrumentLabel.centerYAnchor.constraint(equalTo: instrumentRowContainer.centerYAnchor), 1612 - instrumentLabel.trailingAnchor.constraint(equalTo: instrumentRowContainer.trailingAnchor, constant: -6), 1613 - ]) 1614 - applyAppearanceToVisualizer() 1615 - applyWaveformTint() 1616 - refreshReadout() 1617 - setControlsVisible(false, animated: false) 1618 - 1619 - panel = p 1620 - } 1621 - 1622 - private func positionPanel(_ panel: NSPanel) { 1623 - guard let target = targetFrame() else { return } 1624 - waveformBezelHeightConstraint?.constant = currentWaveformBezelHeight(for: target.width) 1625 - panel.setFrame(target, display: true) 1626 - } 1627 - 1628 - private func applyAppearanceToVisualizer() { 1629 - let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 1630 - waveformView.setLightMode(!isDark) 1631 - if isDark { 1632 - waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.04).cgColor 1633 - instrumentLabel.textColor = .labelColor 1634 - } else { 1635 - waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor 1636 - instrumentLabel.textColor = .labelColor 1637 - } 1638 - } 1639 - 1640 - private func applyWaveformTint() { 1641 - let stripTint: NSColor 1642 - if menuBand.midiMode { 1643 - waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 1644 - waveformView.setBaseColor(.controlAccentColor) 1645 - waveformBezel.layer?.borderColor = NSColor.controlAccentColor 1646 - .withAlphaComponent(0.24).cgColor 1647 - stripTint = NSColor.controlAccentColor.withAlphaComponent(0.20) 1648 - } else { 1649 - waveformView.setDotMatrix(nil) 1650 - let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 1651 - let familyColor = InstrumentListView.colorForProgram(safe) 1652 - waveformView.setBaseColor(familyColor) 1653 - waveformBezel.layer?.borderColor = familyColor 1654 - .withAlphaComponent(0.22).cgColor 1655 - stripTint = familyColor.withAlphaComponent(0.16) 1656 - } 1657 - if #available(macOS 26.0, *), 1658 - let glassView = stripBackgroundView as? NSGlassEffectView { 1659 - glassView.tintColor = stripTint 1660 - } 1661 - if #available(macOS 26.0, *), 1662 - let glassView = waveformGlassView as? NSGlassEffectView { 1663 - glassView.tintColor = stripTint.withAlphaComponent(0.55) 1664 - } 1665 - if #available(macOS 26.0, *) { 1666 - for view in [closeButtonGlassView, dockButtonGlassView, expandButtonGlassView] { 1667 - (view as? NSGlassEffectView)?.style = .clear 1668 - (view as? NSGlassEffectView)?.tintColor = stripTint.withAlphaComponent(0.34) 1669 - } 1670 - for button in [closeButton, dockButton, expandButton] { 1671 - button.layer?.backgroundColor = NSColor.clear.cgColor 1672 - button.layer?.borderColor = NSColor.clear.cgColor 1673 - } 1674 - } else { 1675 - for button in [closeButton, dockButton, expandButton] { 1676 - button.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.22).cgColor 1677 - button.layer?.borderColor = NSColor.white.withAlphaComponent(0.28).cgColor 1678 - } 1679 - } 1680 - } 1681 - 1682 - private func refreshReadout() { 1683 - let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 1684 - let familyColor = menuBand.midiMode 1685 - ? NSColor.controlAccentColor 1686 - : InstrumentListView.colorForProgram(safe) 1687 - let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 1688 - let textColor: NSColor = isDark ? .white : .black 1689 - let shadow = NSShadow() 1690 - shadow.shadowColor = (familyColor.highlight(withLevel: isDark ? 0.3 : 0.7) ?? familyColor) 1691 - shadow.shadowOffset = NSSize(width: 1, height: -1) 1692 - shadow.shadowBlurRadius = 0 1693 - let titleFont: NSFont = { 1694 - if let desc = AppDelegate.ywftBoldDescriptor, 1695 - let f = NSFont(descriptor: desc, size: 14), 1696 - f.familyName == "YWFT Processing" { 1697 - return f 1698 - } 1699 - return NSFont.systemFont(ofSize: 14, weight: .black) 1700 - }() 1701 - instrumentLabel.attributedStringValue = NSAttributedString( 1702 - string: GeneralMIDI.programNames[safe], 1703 - attributes: [ 1704 - .font: titleFont, 1705 - .foregroundColor: textColor, 1706 - .shadow: shadow, 1707 - ] 1708 - ) 1709 - for view in heldNotesStack.arrangedSubviews { 1710 - heldNotesStack.removeArrangedSubview(view) 1711 - view.removeFromSuperview() 1712 - } 1713 - for name in menuBand.heldNoteNames() { 1714 - heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, color: familyColor)) 1715 - } 1716 - } 1717 - 1718 - private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 1719 - let box = NSView() 1720 - box.wantsLayer = true 1721 - box.layer?.cornerRadius = 4 1722 - box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 1723 - box.translatesAutoresizingMaskIntoConstraints = false 1724 - let label = NSTextField(labelWithString: name) 1725 - label.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .heavy) 1726 - label.textColor = .black 1727 - label.drawsBackground = false 1728 - label.translatesAutoresizingMaskIntoConstraints = false 1729 - box.addSubview(label) 1730 - NSLayoutConstraint.activate([ 1731 - label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 1732 - label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 1733 - label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 1734 - label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 1735 - ]) 1736 - return box 1737 - } 1738 - 1739 - private func configureControlButton(_ button: NSButton, 1740 - symbolName: String, 1741 - toolTip: String, 1742 - action: Selector) { 1743 - let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) 1744 - button.image = NSImage( 1745 - systemSymbolName: symbolName, 1746 - accessibilityDescription: toolTip 1747 - )?.withSymbolConfiguration(config) 1748 - button.isBordered = false 1749 - button.setButtonType(.momentaryChange) 1750 - button.imagePosition = .imageOnly 1751 - button.contentTintColor = .white.withAlphaComponent(0.92) 1752 - button.toolTip = toolTip 1753 - button.target = self 1754 - button.action = action 1755 - button.alphaValue = 0 1756 - button.wantsLayer = true 1757 - button.layer?.cornerRadius = Self.controlButtonSize / 2 1758 - button.layer?.borderWidth = 1 1759 - } 1760 - 1761 - @objc private func closeButtonClicked(_ sender: NSButton) { 1762 - dismiss() 1763 - } 1764 - 1765 - @objc private func dockButtonClicked(_ sender: NSButton) { 1766 - resetCustomPosition() 1767 - } 1768 - 1769 - @objc private func expandButtonClicked(_ sender: NSButton) { 1770 - onExpandRequested?() 1771 - } 1772 - 1773 - @available(macOS 26.0, *) 1774 - private func installControlGlassBackground(for button: NSButton, in container: NSView) -> NSView { 1775 - let glassView = StripMovableGlassEffectView() 1776 - glassView.translatesAutoresizingMaskIntoConstraints = false 1777 - glassView.cornerRadius = Self.controlButtonSize / 2 1778 - glassView.style = .clear 1779 - glassView.alphaValue = 0 1780 - container.addSubview(glassView, positioned: .below, relativeTo: button) 1781 - NSLayoutConstraint.activate([ 1782 - glassView.leadingAnchor.constraint(equalTo: button.leadingAnchor), 1783 - glassView.trailingAnchor.constraint(equalTo: button.trailingAnchor), 1784 - glassView.topAnchor.constraint(equalTo: button.topAnchor), 1785 - glassView.bottomAnchor.constraint(equalTo: button.bottomAnchor), 1786 - ]) 1787 - return glassView 1788 - } 1789 - 1790 - private func setControlsVisible(_ isVisible: Bool, animated: Bool = true) { 1791 - let alpha: CGFloat = isVisible ? 1.0 : 0.0 1792 - if animated { 1793 - NSAnimationContext.runAnimationGroup { context in 1794 - context.duration = 0.12 1795 - closeButton.animator().alphaValue = alpha 1796 - dockButton.animator().alphaValue = alpha 1797 - expandButton.animator().alphaValue = alpha 1798 - closeButtonGlassView?.animator().alphaValue = alpha 1799 - dockButtonGlassView?.animator().alphaValue = alpha 1800 - expandButtonGlassView?.animator().alphaValue = alpha 1801 - } 1802 - } else { 1803 - closeButton.alphaValue = alpha 1804 - dockButton.alphaValue = alpha 1805 - expandButton.alphaValue = alpha 1806 - closeButtonGlassView?.alphaValue = alpha 1807 - dockButtonGlassView?.alphaValue = alpha 1808 - expandButtonGlassView?.alphaValue = alpha 1809 - } 1810 - } 1811 - 1812 - }
-175
slab/menuband/Sources/MenuBand/PianoWaveformPalette.swift
··· 1 - import AppKit 2 - 3 - final class PianoWaveformPalette { 4 - enum State { 5 - case collapsed 6 - case expanded 7 - } 8 - 9 - private let menuBand: MenuBandController 10 - private let expandedPalette: FloatingPlayPaletteController 11 - private let collapsedPalette: MenuBarWaveformStrip 12 - private weak var statusItemButton: NSStatusBarButton? 13 - private var state: State = .collapsed 14 - private var dismissHandler: (() -> Void)? 15 - 16 - init(menuBand: MenuBandController) { 17 - self.menuBand = menuBand 18 - expandedPalette = FloatingPlayPaletteController(menuBand: menuBand) 19 - collapsedPalette = MenuBarWaveformStrip(menuBand: menuBand) 20 - expandedPalette.onDismiss = { [weak self] in 21 - self?.state = .collapsed 22 - self?.dismissHandler?() 23 - } 24 - expandedPalette.onExpandCollapseToggle = { [weak self] in 25 - self?.collapse() 26 - } 27 - collapsedPalette.onExpandRequested = { [weak self] in 28 - self?.expandFromCollapsed() 29 - } 30 - } 31 - 32 - var expandedState: State { .expanded } 33 - var collapsedState: State { .collapsed } 34 - 35 - var onDismiss: (() -> Void)? { 36 - get { dismissHandler } 37 - set { dismissHandler = newValue } 38 - } 39 - 40 - var onFocusRelease: (() -> Void)? { 41 - get { expandedPalette.onFocusRelease } 42 - set { expandedPalette.onFocusRelease = newValue } 43 - } 44 - 45 - var onToggleKeymap: (() -> Void)? { 46 - get { expandedPalette.onToggleKeymap } 47 - set { expandedPalette.onToggleKeymap = newValue } 48 - } 49 - 50 - var isPianoFocusActive: (() -> Bool)? { 51 - get { expandedPalette.isPianoFocusActive } 52 - set { expandedPalette.isPianoFocusActive = newValue } 53 - } 54 - 55 - var isShown: Bool { expandedPalette.isShown } 56 - var isKeyboardFocused: Bool { expandedPalette.isKeyboardFocused } 57 - var isCollapsedState: Bool { state == .collapsed } 58 - 59 - var onStepBackward: (() -> Void)? { 60 - get { collapsedPalette.onStepBackward } 61 - set { collapsedPalette.onStepBackward = newValue } 62 - } 63 - 64 - var onStepForward: (() -> Void)? { 65 - get { collapsedPalette.onStepForward } 66 - set { collapsedPalette.onStepForward = newValue } 67 - } 68 - 69 - var onStepUp: (() -> Void)? { 70 - get { collapsedPalette.onStepUp } 71 - set { collapsedPalette.onStepUp = newValue } 72 - } 73 - 74 - var onStepDown: (() -> Void)? { 75 - get { collapsedPalette.onStepDown } 76 - set { collapsedPalette.onStepDown = newValue } 77 - } 78 - 79 - var suppressed: Bool { 80 - get { collapsedPalette.suppressed } 81 - set { collapsedPalette.suppressed = newValue } 82 - } 83 - 84 - var isDocked: Bool { collapsedPalette.isDocked } 85 - 86 - func toggleFromShortcut() { 87 - if expandedPalette.isShown { 88 - state = .collapsed 89 - expandedPalette.toggleFromShortcut() 90 - return 91 - } 92 - collapsedPalette.dismiss() 93 - state = .expanded 94 - expandedPalette.toggleFromShortcut() 95 - } 96 - 97 - func showFromCommand(restoringTo previousApp: NSRunningApplication? = nil) { 98 - collapsedPalette.dismiss() 99 - state = .expanded 100 - expandedPalette.showFromCommand(restoringTo: previousApp) 101 - } 102 - 103 - func show(restoringTo previousApp: NSRunningApplication? = nil) { 104 - collapsedPalette.dismiss() 105 - state = .expanded 106 - expandedPalette.show(restoringTo: previousApp) 107 - } 108 - 109 - func dismiss(reason: FloatingPlayPaletteController.DismissReason = .programmatic) { 110 - state = .collapsed 111 - expandedPalette.dismiss(reason: reason) 112 - } 113 - 114 - func refresh() { 115 - expandedPalette.refresh() 116 - } 117 - 118 - func clearInteraction() { 119 - expandedPalette.clearInteraction() 120 - } 121 - 122 - func releaseKeyboardFocus() { 123 - expandedPalette.releaseKeyboardFocus() 124 - } 125 - 126 - func warmUp() { 127 - collapsedPalette.warmUp() 128 - } 129 - 130 - func showIfNeeded() { 131 - guard state == .collapsed else { return } 132 - collapsedPalette.reposition(statusItemButton: statusItemButton) 133 - collapsedPalette.refreshAppearance() 134 - collapsedPalette.showIfNeeded() 135 - } 136 - 137 - func scheduleHide() { 138 - guard state == .collapsed else { return } 139 - collapsedPalette.scheduleHide() 140 - } 141 - 142 - func reposition(statusItemButton: NSStatusBarButton?) { 143 - self.statusItemButton = statusItemButton 144 - collapsedPalette.reposition(statusItemButton: statusItemButton) 145 - } 146 - 147 - func refreshAppearance() { 148 - guard state == .collapsed else { return } 149 - collapsedPalette.refreshAppearance() 150 - } 151 - 152 - func registerArrowInput() { 153 - guard state == .collapsed else { return } 154 - collapsedPalette.registerArrowInput() 155 - } 156 - 157 - private func collapse() { 158 - guard state != .collapsed else { return } 159 - state = .collapsed 160 - expandedPalette.dismiss(reason: .closeButton) 161 - collapsedPalette.reposition(statusItemButton: statusItemButton) 162 - collapsedPalette.refreshAppearance() 163 - collapsedPalette.showIfNeeded() 164 - if menuBand.litNotes.isEmpty { 165 - collapsedPalette.scheduleHide() 166 - } 167 - } 168 - 169 - private func expandFromCollapsed() { 170 - guard state != .expanded else { return } 171 - state = .expanded 172 - collapsedPalette.dismiss() 173 - expandedPalette.show() 174 - } 175 - }
+1
slab/menuband/Sources/MenuBand/QwertyLayoutView.swift
··· 63 63 required init?(coder: NSCoder) { fatalError() } 64 64 65 65 override var isFlipped: Bool { false } 66 + override var mouseDownCanMoveWindow: Bool { false } 66 67 67 68 /// Three-row macOS QWERTY layout — keys identified by `kVK_*` 68 69 /// codes, mirroring `MenuBandLayout.panByKeyCode` so the visual
+977
slab/menuband/Sources/MenuBand/UnifiedPianoWaveformPalette.swift
··· 1 + import AppKit 2 + 3 + final class UnifiedPianoWaveformPalette: NSObject, NSWindowDelegate { 4 + enum State { 5 + case collapsed 6 + case expanded 7 + } 8 + 9 + enum DismissReason { 10 + case closeButton 11 + case shortcut 12 + case programmatic 13 + 14 + var shouldRestoreFocus: Bool { 15 + switch self { 16 + case .closeButton, .shortcut: 17 + return true 18 + case .programmatic: 19 + return false 20 + } 21 + } 22 + } 23 + 24 + private let menuBand: MenuBandController 25 + private let paletteController: FloatingPlayPaletteViewController 26 + private var panel: UnifiedPianoWaveformPalettePanel? 27 + private weak var statusItemButton: NSStatusBarButton? 28 + private var state: State = .collapsed 29 + private var preferredState: State 30 + private var keyMonitor: Any? 31 + private var appBeforeOpen: NSRunningApplication? 32 + private var dismissHandler: (() -> Void)? 33 + private var isDismissing = false 34 + private var isPositioningPanel = false 35 + private var savedExpandedOrigin: NSPoint? 36 + private var collapsedCustomOrigin: NSPoint? 37 + private var hideWorkItem: DispatchWorkItem? 38 + private var isEnabled: Bool 39 + 40 + private static let expandedOriginXKey = "notepat.unifiedPalette.expandedOriginX" 41 + private static let expandedOriginYKey = "notepat.unifiedPalette.expandedOriginY" 42 + private static let collapsedOriginXKey = "notepat.unifiedPalette.collapsedOriginX" 43 + private static let collapsedOriginYKey = "notepat.unifiedPalette.collapsedOriginY" 44 + private static let enabledKey = "notepat.unifiedPalette.enabled" 45 + private static let preferredStateKey = "notepat.unifiedPalette.preferredState" 46 + private static let collapsedHideDelay: TimeInterval = 2.0 47 + 48 + var onDismiss: (() -> Void)? { 49 + get { dismissHandler } 50 + set { dismissHandler = newValue } 51 + } 52 + 53 + var onFocusRelease: (() -> Void)? 54 + 55 + var onToggleKeymap: (() -> Void)? 56 + 57 + var isPianoFocusActive: (() -> Bool)? { 58 + get { paletteController.isPianoFocusActive } 59 + set { paletteController.isPianoFocusActive = newValue } 60 + } 61 + 62 + var isShown: Bool { 63 + state == .expanded && panel?.isVisible == true 64 + } 65 + 66 + var isKeyboardFocused: Bool { 67 + state == .expanded && panel?.isKeyWindow == true 68 + } 69 + 70 + var isCollapsedState: Bool { state == .collapsed } 71 + 72 + var isFeatureEnabled: Bool { isEnabled } 73 + 74 + var onStepBackward: (() -> Void)? { 75 + get { paletteController.onStepBackward } 76 + set { paletteController.onStepBackward = newValue } 77 + } 78 + 79 + var onStepForward: (() -> Void)? { 80 + get { paletteController.onStepForward } 81 + set { paletteController.onStepForward = newValue } 82 + } 83 + 84 + var onStepUp: (() -> Void)? { 85 + get { paletteController.onStepUp } 86 + set { paletteController.onStepUp = newValue } 87 + } 88 + 89 + var onStepDown: (() -> Void)? { 90 + get { paletteController.onStepDown } 91 + set { paletteController.onStepDown = newValue } 92 + } 93 + 94 + var suppressed: Bool = false { 95 + didSet { 96 + guard suppressed != oldValue else { return } 97 + if suppressed && state == .collapsed { 98 + dismissCollapsedPanel() 99 + } 100 + } 101 + } 102 + 103 + var isDocked: Bool { collapsedCustomOrigin == nil } 104 + 105 + init(menuBand: MenuBandController) { 106 + self.menuBand = menuBand 107 + self.paletteController = FloatingPlayPaletteViewController(menuBand: menuBand) 108 + self.preferredState = Self.loadPreferredState() 109 + self.isEnabled = Self.loadEnabledState() 110 + self.savedExpandedOrigin = Self.loadOrigin( 111 + xKey: Self.expandedOriginXKey, 112 + yKey: Self.expandedOriginYKey 113 + ) 114 + self.collapsedCustomOrigin = Self.loadOrigin( 115 + xKey: Self.collapsedOriginXKey, 116 + yKey: Self.collapsedOriginYKey 117 + ) 118 + super.init() 119 + 120 + paletteController.onCloseRequested = { [weak self] in 121 + self?.disable(reason: .closeButton) 122 + } 123 + paletteController.onDockRequested = { [weak self] in 124 + self?.dockOnMenu() 125 + } 126 + paletteController.onTogglePresentationMode = { [weak self] in 127 + self?.togglePresentationMode() 128 + } 129 + } 130 + 131 + func toggleFromShortcut() { 132 + if isEnabled { 133 + disable(reason: .shortcut) 134 + return 135 + } 136 + enableAndShowPreferred(restoringTo: nil) 137 + } 138 + 139 + func showFromCommand(restoringTo previousApp: NSRunningApplication? = nil) { 140 + setEnabled(true) 141 + preferredState = .expanded 142 + persistPreferredState() 143 + transitionToExpanded() 144 + showExpanded(restoringTo: previousApp) 145 + } 146 + 147 + func show(restoringTo previousApp: NSRunningApplication? = nil) { 148 + setEnabled(true) 149 + preferredState = .expanded 150 + persistPreferredState() 151 + transitionToExpanded() 152 + showExpanded(restoringTo: previousApp) 153 + } 154 + 155 + func dismiss(reason: DismissReason = .programmatic) { 156 + guard state == .expanded else { return } 157 + dismissExpanded(reason: reason) 158 + } 159 + 160 + func refresh() { 161 + switch state { 162 + case .expanded: 163 + paletteController.setDisplayMode(.expanded) 164 + paletteController.refresh() 165 + if let panel, panel.isVisible { 166 + setPanelFrame(expandedFrame( 167 + size: paletteController.preferredContentSize, 168 + fallbackOrigin: panel.frame.origin 169 + )) 170 + } 171 + case .collapsed: 172 + paletteController.setDisplayMode(.collapsed) 173 + paletteController.refresh() 174 + if let panel, panel.isVisible { 175 + setPanelFrame(collapsedFrame(size: paletteController.preferredContentSize)) 176 + } 177 + } 178 + } 179 + 180 + func clearInteraction() { 181 + paletteController.clearInteraction() 182 + } 183 + 184 + func releaseKeyboardFocus() { 185 + guard state == .expanded else { return } 186 + restorePreviousAppFocus() 187 + } 188 + 189 + func warmUp() { 190 + if panel == nil { 191 + buildPanel() 192 + } 193 + paletteController.setDisplayMode(.collapsed) 194 + paletteController.refresh() 195 + } 196 + 197 + func showIfNeeded() { 198 + guard state == .collapsed, !suppressed, isEnabled, preferredState == .collapsed else { return } 199 + cancelPendingHide() 200 + if panel == nil { 201 + buildPanel() 202 + } 203 + paletteController.setDisplayMode(.collapsed) 204 + paletteController.refresh() 205 + showCollapsedIfNeeded() 206 + if !menuBand.litNotes.isEmpty { 207 + focusCollapsedPaletteIfNeeded() 208 + } 209 + } 210 + 211 + func scheduleHide() { 212 + guard state == .collapsed else { return } 213 + cancelPendingHide() 214 + let work = DispatchWorkItem { [weak self] in 215 + self?.dismissCollapsedPanel() 216 + } 217 + hideWorkItem = work 218 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.collapsedHideDelay, execute: work) 219 + } 220 + 221 + func reposition(statusItemButton: NSStatusBarButton?) { 222 + self.statusItemButton = statusItemButton 223 + guard state == .collapsed, let panel, panel.isVisible else { return } 224 + setPanelFrame(collapsedFrame(size: paletteController.preferredContentSize)) 225 + } 226 + 227 + func refreshAppearance() { 228 + guard state == .collapsed else { return } 229 + paletteController.setDisplayMode(.collapsed) 230 + paletteController.refresh() 231 + if let panel, panel.isVisible { 232 + setPanelFrame(collapsedFrame(size: paletteController.preferredContentSize)) 233 + } 234 + } 235 + 236 + func registerArrowInput() { 237 + guard state == .collapsed, !suppressed, isEnabled else { return } 238 + showIfNeeded() 239 + paletteController.refresh() 240 + focusCollapsedPaletteIfNeeded() 241 + if menuBand.litNotes.isEmpty { 242 + scheduleHide() 243 + } 244 + } 245 + 246 + func windowDidMove(_ notification: Notification) { 247 + guard let panel, !isPositioningPanel else { return } 248 + switch state { 249 + case .expanded: 250 + savedExpandedOrigin = panel.frame.origin 251 + persistOrigin(savedExpandedOrigin, xKey: Self.expandedOriginXKey, yKey: Self.expandedOriginYKey) 252 + case .collapsed: 253 + collapsedCustomOrigin = panel.frame.origin 254 + persistOrigin(collapsedCustomOrigin, xKey: Self.collapsedOriginXKey, yKey: Self.collapsedOriginYKey) 255 + } 256 + } 257 + 258 + private func buildPanel() { 259 + let panel = UnifiedPianoWaveformPalettePanel( 260 + contentRect: NSRect(origin: .zero, size: paletteController.preferredContentSize), 261 + styleMask: [.titled, .closable, .fullSizeContentView], 262 + backing: .buffered, 263 + defer: false 264 + ) 265 + panel.delegate = self 266 + panel.isOpaque = false 267 + panel.backgroundColor = .clear 268 + panel.hasShadow = true 269 + panel.level = .floating 270 + panel.animationBehavior = .none 271 + panel.collectionBehavior = [.transient] 272 + panel.hidesOnDeactivate = false 273 + panel.canHide = false 274 + panel.isMovableByWindowBackground = true 275 + panel.acceptsMouseMovedEvents = true 276 + panel.titleVisibility = .hidden 277 + panel.titlebarAppearsTransparent = true 278 + panel.isReleasedWhenClosed = false 279 + panel.contentViewController = paletteController 280 + if let button = panel.standardWindowButton(.closeButton) { 281 + button.isHidden = true 282 + } 283 + if let mini = panel.standardWindowButton(.miniaturizeButton) { 284 + mini.isHidden = true 285 + } 286 + if let zoom = panel.standardWindowButton(.zoomButton) { 287 + zoom.isHidden = true 288 + } 289 + self.panel = panel 290 + } 291 + 292 + private func showExpanded(restoringTo previousApp: NSRunningApplication?) { 293 + if panel == nil { 294 + buildPanel() 295 + } 296 + guard let panel else { return } 297 + cancelPendingHide() 298 + setEnabled(true) 299 + appBeforeOpen = previousApp ?? currentFrontmostOtherApp() 300 + paletteController.setDisplayMode(.expanded) 301 + paletteController.refresh() 302 + panel.ignoresMouseEvents = false 303 + setPanelFrame(expandedFrame( 304 + size: paletteController.preferredContentSize, 305 + fallbackOrigin: panel.frame.origin 306 + )) 307 + NSApp.activate(ignoringOtherApps: true) 308 + panel.makeKeyAndOrderFront(nil) 309 + paletteController.setPresented(true) 310 + installMonitors() 311 + } 312 + 313 + private func showCollapsedIfNeeded() { 314 + guard let panel else { return } 315 + paletteController.setDisplayMode(.collapsed) 316 + panel.ignoresMouseEvents = false 317 + setPanelFrame(collapsedFrame(size: paletteController.preferredContentSize)) 318 + if !panel.isVisible { 319 + panel.orderFrontRegardless() 320 + } 321 + paletteController.setPresented(true) 322 + } 323 + 324 + private func focusCollapsedPaletteIfNeeded() { 325 + guard state == .collapsed, let panel else { return } 326 + NSApp.activate(ignoringOtherApps: true) 327 + panel.makeKeyAndOrderFront(nil) 328 + paletteController.setPresented(true) 329 + } 330 + 331 + private func dismissExpanded(reason: DismissReason) { 332 + guard !isDismissing else { return } 333 + guard panel?.isVisible == true || keyMonitor != nil else { return } 334 + isDismissing = true 335 + removeMonitors() 336 + paletteController.setPresented(false) 337 + paletteController.clearInteraction() 338 + menuBand.releaseAllHeldNotes() 339 + panel?.ignoresMouseEvents = false 340 + panel?.orderOut(nil) 341 + state = .collapsed 342 + dismissHandler?() 343 + if reason.shouldRestoreFocus { 344 + restorePreviousAppFocus() 345 + } 346 + appBeforeOpen = nil 347 + isDismissing = false 348 + } 349 + 350 + private func dismissCollapsedPanel() { 351 + cancelPendingHide() 352 + paletteController.setPresented(false) 353 + panel?.ignoresMouseEvents = false 354 + panel?.orderOut(nil) 355 + } 356 + 357 + private func togglePresentationMode() { 358 + switch state { 359 + case .collapsed: 360 + expandFromStrip() 361 + case .expanded: 362 + collapseToStrip() 363 + } 364 + } 365 + 366 + private func dockOnMenu() { 367 + collapsedCustomOrigin = nil 368 + persistOrigin(nil, xKey: Self.collapsedOriginXKey, yKey: Self.collapsedOriginYKey) 369 + collapseToStrip() 370 + if let panel, panel.isVisible { 371 + setPanelFrame(collapsedFrame(size: paletteController.preferredContentSize)) 372 + } 373 + } 374 + 375 + private func transitionToExpanded() { 376 + if panel == nil { 377 + buildPanel() 378 + } 379 + state = .expanded 380 + preferredState = .expanded 381 + persistPreferredState() 382 + panel?.ignoresMouseEvents = false 383 + paletteController.setDisplayMode(.expanded) 384 + } 385 + 386 + private func collapseToStrip() { 387 + guard state != .collapsed else { 388 + preferredState = .collapsed 389 + persistPreferredState() 390 + paletteController.setDisplayMode(.collapsed) 391 + paletteController.refresh() 392 + showIfNeeded() 393 + if menuBand.litNotes.isEmpty { 394 + scheduleHide() 395 + } 396 + return 397 + } 398 + removeMonitors() 399 + paletteController.setPresented(false) 400 + paletteController.clearInteraction() 401 + state = .collapsed 402 + preferredState = .collapsed 403 + persistPreferredState() 404 + paletteController.setDisplayMode(.collapsed) 405 + paletteController.refresh() 406 + showCollapsedIfNeeded() 407 + if menuBand.litNotes.isEmpty { 408 + scheduleHide() 409 + } 410 + } 411 + 412 + private func expandFromStrip() { 413 + transitionToExpanded() 414 + showExpanded(restoringTo: nil) 415 + } 416 + 417 + private func installMonitors() { 418 + if keyMonitor == nil { 419 + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 420 + guard let self, self.state == .expanded, self.panel?.isKeyWindow == true else { return event } 421 + let isDown = event.type == .keyDown 422 + if isDown && event.keyCode == 53 { 423 + self.onFocusRelease?() 424 + return nil 425 + } 426 + if isDown && MenuBandShortcutPreferences.focusShortcut.matches(event: event) { 427 + self.onFocusRelease?() 428 + return nil 429 + } 430 + if isDown && MenuBandShortcut.layoutToggle.matches(event: event) { 431 + self.onToggleKeymap?() 432 + return nil 433 + } 434 + let consumed = self.menuBand.handleLocalKey( 435 + keyCode: event.keyCode, 436 + isDown: isDown, 437 + isRepeat: event.isARepeat, 438 + flags: event.modifierFlags 439 + ) 440 + if consumed { 441 + self.refresh() 442 + return nil 443 + } 444 + return event 445 + } 446 + } 447 + } 448 + 449 + private func removeMonitors() { 450 + if let keyMonitor { 451 + NSEvent.removeMonitor(keyMonitor) 452 + self.keyMonitor = nil 453 + } 454 + } 455 + 456 + private func expandedFrame(size: NSSize, fallbackOrigin: NSPoint?) -> NSRect { 457 + let origin = fallbackOrigin ?? savedExpandedOrigin 458 + return clampedFrame( 459 + origin: origin ?? centeredOrigin(for: size), 460 + size: size, 461 + preferredScreen: panel?.screen ?? NSScreen.main 462 + ) 463 + } 464 + 465 + private func collapsedFrame(size: NSSize) -> NSRect { 466 + guard let anchoredFrame = anchoredCollapsedFrame(size: size) else { 467 + return clampedFrame( 468 + origin: collapsedCustomOrigin ?? centeredOrigin(for: size), 469 + size: size, 470 + preferredScreen: panel?.screen ?? NSScreen.main 471 + ) 472 + } 473 + guard let collapsedCustomOrigin else { return anchoredFrame } 474 + return clampedFrame( 475 + origin: collapsedCustomOrigin, 476 + size: anchoredFrame.size, 477 + preferredScreen: panel?.screen ?? statusItemButton?.window?.screen 478 + ) 479 + } 480 + 481 + private func anchoredCollapsedFrame(size: NSSize) -> NSRect? { 482 + guard let button = statusItemButton, 483 + let buttonWindow = button.window else { return nil } 484 + 485 + let imgSize = KeyboardIconRenderer.imageSize 486 + let buttonBounds = button.bounds 487 + let xOffset = (buttonBounds.width - imgSize.width) / 2.0 488 + let pianoOriginX = xOffset + KeyboardIconRenderer.pad 489 + let pianoWidth = imgSize.width - KeyboardIconRenderer.settingsW 490 + - KeyboardIconRenderer.settingsGap - KeyboardIconRenderer.pad * 2 491 + 492 + let localRect = NSRect(x: pianoOriginX, y: 0, width: pianoWidth, height: buttonBounds.height) 493 + let windowRect = button.convert(localRect, to: nil) 494 + let screenRect = buttonWindow.convertToScreen(windowRect) 495 + return NSRect( 496 + x: screenRect.origin.x, 497 + y: screenRect.origin.y - size.height, 498 + width: screenRect.width, 499 + height: size.height 500 + ) 501 + } 502 + 503 + private func centeredOrigin(for size: NSSize) -> NSPoint { 504 + let mouse = NSEvent.mouseLocation 505 + let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } 506 + ?? NSScreen.main 507 + ?? NSScreen.screens.first 508 + let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 509 + return NSPoint( 510 + x: visible.midX - size.width / 2, 511 + y: visible.midY - size.height / 2 512 + ) 513 + } 514 + 515 + private func clampedFrame(origin: NSPoint, size: NSSize, preferredScreen: NSScreen?) -> NSRect { 516 + let visible = visibleFrame(for: origin, preferredScreen: preferredScreen) 517 + let margin: CGFloat = 16 518 + let x = min(max(origin.x, visible.minX + margin), visible.maxX - size.width - margin) 519 + let y = min(max(origin.y, visible.minY + margin), visible.maxY - size.height - margin) 520 + return NSRect(origin: NSPoint(x: x, y: y), size: size) 521 + } 522 + 523 + private func visibleFrame(for origin: NSPoint, preferredScreen: NSScreen?) -> NSRect { 524 + if let screen = NSScreen.screens.first(where: { $0.visibleFrame.contains(origin) }) { 525 + return screen.visibleFrame 526 + } 527 + if let preferredScreen { 528 + return preferredScreen.visibleFrame 529 + } 530 + return NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) 531 + } 532 + 533 + private func setPanelFrame(_ frame: NSRect) { 534 + guard let panel else { return } 535 + isPositioningPanel = true 536 + panel.setFrame(frame, display: true) 537 + isPositioningPanel = false 538 + } 539 + 540 + private func cancelPendingHide() { 541 + hideWorkItem?.cancel() 542 + hideWorkItem = nil 543 + } 544 + 545 + private func enableAndShowPreferred(restoringTo previousApp: NSRunningApplication?) { 546 + setEnabled(true) 547 + switch preferredState { 548 + case .expanded: 549 + transitionToExpanded() 550 + showExpanded(restoringTo: previousApp) 551 + case .collapsed: 552 + state = .collapsed 553 + showIfNeeded() 554 + focusCollapsedPaletteIfNeeded() 555 + } 556 + } 557 + 558 + private func disable(reason: DismissReason) { 559 + setEnabled(false) 560 + switch state { 561 + case .expanded: 562 + dismissExpanded(reason: reason) 563 + case .collapsed: 564 + dismissCollapsedPanel() 565 + } 566 + } 567 + 568 + private func setEnabled(_ isEnabled: Bool) { 569 + guard self.isEnabled != isEnabled else { return } 570 + self.isEnabled = isEnabled 571 + UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) 572 + } 573 + 574 + private func persistPreferredState() { 575 + let value = preferredState == .expanded ? "expanded" : "collapsed" 576 + UserDefaults.standard.set(value, forKey: Self.preferredStateKey) 577 + } 578 + 579 + private func currentFrontmostOtherApp() -> NSRunningApplication? { 580 + let frontmost = NSWorkspace.shared.frontmostApplication 581 + guard frontmost?.bundleIdentifier != Bundle.main.bundleIdentifier else { return nil } 582 + return frontmost 583 + } 584 + 585 + private func restorePreviousAppFocus() { 586 + guard let app = appBeforeOpen, 587 + !app.isTerminated, 588 + app.bundleIdentifier != Bundle.main.bundleIdentifier else { return } 589 + app.activate(options: [.activateIgnoringOtherApps]) 590 + } 591 + 592 + private func persistOrigin(_ origin: NSPoint?, xKey: String, yKey: String) { 593 + let defaults = UserDefaults.standard 594 + guard let origin else { 595 + defaults.removeObject(forKey: xKey) 596 + defaults.removeObject(forKey: yKey) 597 + return 598 + } 599 + defaults.set(origin.x, forKey: xKey) 600 + defaults.set(origin.y, forKey: yKey) 601 + } 602 + 603 + private static func loadOrigin(xKey: String, yKey: String) -> NSPoint? { 604 + let defaults = UserDefaults.standard 605 + guard defaults.object(forKey: xKey) != nil, 606 + defaults.object(forKey: yKey) != nil else { return nil } 607 + return NSPoint( 608 + x: defaults.double(forKey: xKey), 609 + y: defaults.double(forKey: yKey) 610 + ) 611 + } 612 + 613 + private static func loadEnabledState() -> Bool { 614 + UserDefaults.standard.bool(forKey: enabledKey) 615 + } 616 + 617 + private static func loadPreferredState() -> State { 618 + UserDefaults.standard.string(forKey: preferredStateKey) == "expanded" 619 + ? .expanded 620 + : .collapsed 621 + } 622 + } 623 + 624 + private final class UnifiedPianoWaveformPalettePanel: NSPanel { 625 + override var canBecomeKey: Bool { true } 626 + override var canBecomeMain: Bool { false } 627 + } 628 + 629 + @available(macOS 26.0, *) 630 + private final class UnifiedWaveformStripGlassEffectView: NSGlassEffectView { 631 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 632 + } 633 + 634 + final class UnifiedWaveformStripView: NSView { 635 + private static var shouldUseLiquidGlass: Bool { 636 + if #available(macOS 26.0, *) { return true } 637 + return false 638 + } 639 + 640 + private weak var menuBand: MenuBandController? 641 + private let contentContainer = NSView() 642 + private let waveformContainer = NSView() 643 + private let waveformClipView = NSView() 644 + private let waveformView = WaveformView() 645 + private let heldNotesContainer = NSView() 646 + private let heldNotesStack = NSStackView() 647 + private let instrumentRow = NSView() 648 + private let instrumentArrows = ArrowKeysIndicator() 649 + private let instrumentLabel = NSTextField(labelWithString: "") 650 + private var trackingArea: NSTrackingArea? 651 + private weak var paletteGlassView: NSView? 652 + private weak var waveformGlassView: NSView? 653 + private weak var instrumentRowGlassView: NSView? 654 + 655 + var onHoverChanged: ((Bool) -> Void)? 656 + var onStepBackward: (() -> Void)? 657 + var onStepForward: (() -> Void)? 658 + var onStepUp: (() -> Void)? 659 + var onStepDown: (() -> Void)? 660 + 661 + private static let waveformHeight: CGFloat = 64 662 + private static let heldNotesRowHeight: CGFloat = 16 663 + private static let instrumentRowHeight: CGFloat = 22 664 + 665 + init(menuBand: MenuBandController) { 666 + self.menuBand = menuBand 667 + super.init(frame: .zero) 668 + wantsLayer = true 669 + layer?.cornerRadius = 10 670 + layer?.masksToBounds = true 671 + 672 + contentContainer.translatesAutoresizingMaskIntoConstraints = false 673 + waveformView.menuBand = menuBand 674 + waveformView.translatesAutoresizingMaskIntoConstraints = false 675 + waveformView.setSurfaceStyle(.standard) 676 + 677 + waveformContainer.wantsLayer = true 678 + waveformContainer.translatesAutoresizingMaskIntoConstraints = false 679 + waveformContainer.layer?.cornerRadius = 8 680 + waveformContainer.layer?.masksToBounds = false 681 + 682 + waveformClipView.wantsLayer = true 683 + waveformClipView.translatesAutoresizingMaskIntoConstraints = false 684 + waveformClipView.layer?.cornerRadius = 8 685 + waveformClipView.layer?.masksToBounds = true 686 + 687 + heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 688 + heldNotesStack.orientation = .horizontal 689 + heldNotesStack.alignment = .centerY 690 + heldNotesStack.spacing = 4 691 + heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 692 + 693 + instrumentRow.translatesAutoresizingMaskIntoConstraints = false 694 + instrumentRow.wantsLayer = true 695 + instrumentRow.layer?.cornerRadius = 7 696 + if #available(macOS 10.15, *) { 697 + instrumentRow.layer?.cornerCurve = .continuous 698 + } 699 + instrumentArrows.translatesAutoresizingMaskIntoConstraints = false 700 + instrumentArrows.setContentHuggingPriority(.required, for: .horizontal) 701 + instrumentArrows.setContentCompressionResistancePriority(.required, for: .horizontal) 702 + instrumentArrows.displayMode = .horizontalPair 703 + instrumentArrows.style = .prominent 704 + instrumentArrows.toolTip = "Change instrument" 705 + instrumentArrows.onClick = { [weak self] direction, isDown in 706 + guard let self, isDown else { return } 707 + switch direction { 708 + case 0: 709 + self.onStepBackward?() 710 + case 1: 711 + self.onStepForward?() 712 + case 2: 713 + self.onStepDown?() 714 + case 3: 715 + self.onStepUp?() 716 + default: 717 + break 718 + } 719 + } 720 + 721 + instrumentLabel.translatesAutoresizingMaskIntoConstraints = false 722 + instrumentLabel.lineBreakMode = .byTruncatingTail 723 + instrumentLabel.drawsBackground = false 724 + instrumentLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 725 + 726 + addSubview(contentContainer) 727 + contentContainer.addSubview(waveformContainer) 728 + contentContainer.addSubview(heldNotesContainer) 729 + contentContainer.addSubview(instrumentRow) 730 + waveformContainer.addSubview(waveformClipView) 731 + waveformClipView.addSubview(waveformView) 732 + heldNotesContainer.addSubview(heldNotesStack) 733 + instrumentRow.addSubview(instrumentArrows) 734 + instrumentRow.addSubview(instrumentLabel) 735 + installLiquidGlassBackgrounds() 736 + 737 + NSLayoutConstraint.activate([ 738 + widthAnchor.constraint(equalToConstant: KeyboardIconRenderer.imageSize.width), 739 + contentContainer.leadingAnchor.constraint(equalTo: leadingAnchor), 740 + contentContainer.trailingAnchor.constraint(equalTo: trailingAnchor), 741 + contentContainer.topAnchor.constraint(equalTo: topAnchor), 742 + contentContainer.bottomAnchor.constraint(equalTo: bottomAnchor), 743 + 744 + waveformContainer.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 745 + waveformContainer.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 746 + waveformContainer.topAnchor.constraint(equalTo: contentContainer.topAnchor), 747 + waveformContainer.heightAnchor.constraint(equalToConstant: Self.waveformHeight), 748 + 749 + waveformClipView.leadingAnchor.constraint(equalTo: waveformContainer.leadingAnchor, constant: 5), 750 + waveformClipView.trailingAnchor.constraint(equalTo: waveformContainer.trailingAnchor, constant: -5), 751 + waveformClipView.topAnchor.constraint(equalTo: waveformContainer.topAnchor, constant: 5), 752 + waveformClipView.bottomAnchor.constraint(equalTo: waveformContainer.bottomAnchor, constant: -5), 753 + 754 + waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 755 + waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 756 + waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 757 + waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 758 + 759 + heldNotesContainer.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 760 + heldNotesContainer.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 761 + heldNotesContainer.topAnchor.constraint(equalTo: waveformContainer.bottomAnchor, constant: 2), 762 + heldNotesContainer.heightAnchor.constraint(equalToConstant: Self.heldNotesRowHeight), 763 + 764 + heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 765 + heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 766 + 767 + instrumentRow.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 768 + instrumentRow.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 769 + instrumentRow.topAnchor.constraint(equalTo: heldNotesContainer.bottomAnchor, constant: 2), 770 + instrumentRow.heightAnchor.constraint(equalToConstant: Self.instrumentRowHeight), 771 + instrumentRow.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -2), 772 + 773 + instrumentArrows.leadingAnchor.constraint(equalTo: instrumentRow.leadingAnchor, constant: 6), 774 + instrumentArrows.centerYAnchor.constraint(equalTo: instrumentRow.centerYAnchor), 775 + 776 + instrumentLabel.leadingAnchor.constraint(equalTo: instrumentArrows.trailingAnchor, constant: 6), 777 + instrumentLabel.centerYAnchor.constraint(equalTo: instrumentRow.centerYAnchor), 778 + instrumentLabel.trailingAnchor.constraint(equalTo: instrumentRow.trailingAnchor, constant: -8), 779 + ]) 780 + 781 + refresh() 782 + } 783 + 784 + @available(*, unavailable) 785 + required init?(coder: NSCoder) { 786 + nil 787 + } 788 + 789 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 790 + 791 + override func updateTrackingAreas() { 792 + super.updateTrackingAreas() 793 + if let trackingArea { 794 + removeTrackingArea(trackingArea) 795 + } 796 + let trackingArea = NSTrackingArea( 797 + rect: bounds, 798 + options: [.activeAlways, .inVisibleRect, .mouseEnteredAndExited], 799 + owner: self, 800 + userInfo: nil 801 + ) 802 + addTrackingArea(trackingArea) 803 + self.trackingArea = trackingArea 804 + } 805 + 806 + override func mouseEntered(with event: NSEvent) { 807 + onHoverChanged?(true) 808 + super.mouseEntered(with: event) 809 + } 810 + 811 + override func mouseExited(with event: NSEvent) { 812 + onHoverChanged?(false) 813 + super.mouseExited(with: event) 814 + } 815 + 816 + func refresh() { 817 + guard let menuBand else { return } 818 + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 819 + waveformView.setLightMode(!isDark) 820 + 821 + let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 822 + let familyColor = menuBand.midiMode 823 + ? NSColor.controlAccentColor 824 + : InstrumentListView.colorForProgram(safe) 825 + 826 + let waveformBackground = isDark 827 + ? NSColor(white: 0.06, alpha: 1.0) 828 + : NSColor(white: 0.82, alpha: 1.0) 829 + let glassWaveformBackground = isDark 830 + ? NSColor.white.withAlphaComponent(0.04) 831 + : NSColor.white.withAlphaComponent(0.18) 832 + let instrumentRowBackground = isDark 833 + ? NSColor.white.withAlphaComponent(0.08) 834 + : NSColor.white.withAlphaComponent(0.3) 835 + waveformContainer.layer?.backgroundColor = NSColor.clear.cgColor 836 + waveformClipView.layer?.backgroundColor = Self.shouldUseLiquidGlass 837 + ? glassWaveformBackground.cgColor 838 + : waveformBackground.cgColor 839 + waveformContainer.layer?.borderWidth = 1 840 + waveformContainer.layer?.borderColor = familyColor.withAlphaComponent( 841 + Self.shouldUseLiquidGlass ? 0.22 : 0.55 842 + ).cgColor 843 + instrumentRow.layer?.backgroundColor = Self.shouldUseLiquidGlass 844 + ? NSColor.clear.cgColor 845 + : instrumentRowBackground.cgColor 846 + instrumentRow.layer?.borderWidth = 1 847 + instrumentRow.layer?.borderColor = familyColor.withAlphaComponent( 848 + Self.shouldUseLiquidGlass ? 0.22 : 0.35 849 + ).cgColor 850 + instrumentLabel.textColor = isDark ? .white : .black 851 + instrumentArrows.accentColor = familyColor 852 + instrumentArrows.isDarkAppearance = isDark 853 + 854 + if menuBand.midiMode { 855 + waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 856 + waveformView.setBaseColor(.controlAccentColor) 857 + } else { 858 + waveformView.setDotMatrix(nil) 859 + waveformView.setBaseColor(familyColor) 860 + } 861 + 862 + let shadow = NSShadow() 863 + shadow.shadowColor = familyColor.withAlphaComponent(isDark ? 0.9 : 0.55) 864 + shadow.shadowOffset = NSSize(width: 0, height: -1) 865 + shadow.shadowBlurRadius = 3 866 + let titleFont: NSFont = { 867 + if let descriptor = AppDelegate.ywftBoldDescriptor, 868 + let font = NSFont(descriptor: descriptor, size: 14), 869 + font.familyName == "YWFT Processing" { 870 + return font 871 + } 872 + return NSFont.systemFont(ofSize: 14, weight: .black) 873 + }() 874 + instrumentLabel.attributedStringValue = NSAttributedString( 875 + string: GeneralMIDI.programNames[safe], 876 + attributes: [ 877 + .font: titleFont, 878 + .foregroundColor: isDark ? NSColor.white : NSColor.black, 879 + .shadow: shadow, 880 + ] 881 + ) 882 + 883 + for view in heldNotesStack.arrangedSubviews { 884 + heldNotesStack.removeArrangedSubview(view) 885 + view.removeFromSuperview() 886 + } 887 + for name in menuBand.heldNoteNames() { 888 + heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, color: familyColor)) 889 + } 890 + 891 + if Self.shouldUseLiquidGlass, #available(macOS 26.0, *) { 892 + let paletteTint = familyColor.withAlphaComponent(menuBand.midiMode ? 0.20 : 0.16) 893 + (paletteGlassView as? NSGlassEffectView)?.style = .clear 894 + (paletteGlassView as? NSGlassEffectView)?.tintColor = paletteTint 895 + (waveformGlassView as? NSGlassEffectView)?.style = .clear 896 + (waveformGlassView as? NSGlassEffectView)?.tintColor = paletteTint.withAlphaComponent(0.55) 897 + (instrumentRowGlassView as? NSGlassEffectView)?.style = .clear 898 + (instrumentRowGlassView as? NSGlassEffectView)?.tintColor = paletteTint.withAlphaComponent(0.42) 899 + layer?.backgroundColor = NSColor.clear.cgColor 900 + } else { 901 + layer?.backgroundColor = (isDark 902 + ? NSColor(white: 0.06, alpha: 0.96) 903 + : NSColor(white: 0.88, alpha: 0.96)).cgColor 904 + } 905 + } 906 + 907 + func setLive(_ isLive: Bool) { 908 + waveformView.isLive = isLive 909 + } 910 + 911 + private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 912 + let box = NSView() 913 + box.translatesAutoresizingMaskIntoConstraints = false 914 + box.wantsLayer = true 915 + box.layer?.cornerRadius = 4 916 + box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 917 + 918 + let label = NSTextField(labelWithString: name) 919 + label.translatesAutoresizingMaskIntoConstraints = false 920 + label.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .heavy) 921 + label.textColor = .black 922 + box.addSubview(label) 923 + 924 + NSLayoutConstraint.activate([ 925 + label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 926 + label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 927 + label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 928 + label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 929 + ]) 930 + return box 931 + } 932 + 933 + private func installLiquidGlassBackgrounds() { 934 + guard Self.shouldUseLiquidGlass, #available(macOS 26.0, *) else { return } 935 + 936 + let paletteGlassView = UnifiedWaveformStripGlassEffectView() 937 + paletteGlassView.translatesAutoresizingMaskIntoConstraints = false 938 + paletteGlassView.cornerRadius = 10 939 + addSubview(paletteGlassView, positioned: .below, relativeTo: contentContainer) 940 + NSLayoutConstraint.activate([ 941 + paletteGlassView.leadingAnchor.constraint(equalTo: leadingAnchor), 942 + paletteGlassView.trailingAnchor.constraint(equalTo: trailingAnchor), 943 + paletteGlassView.topAnchor.constraint(equalTo: topAnchor), 944 + paletteGlassView.bottomAnchor.constraint(equalTo: bottomAnchor), 945 + ]) 946 + self.paletteGlassView = paletteGlassView 947 + 948 + self.waveformGlassView = installGlassBackground( 949 + matchedTo: waveformContainer, 950 + below: waveformContainer, 951 + cornerRadius: 8 952 + ) 953 + self.instrumentRowGlassView = installGlassBackground( 954 + matchedTo: instrumentRow, 955 + below: instrumentRow, 956 + cornerRadius: 7 957 + ) 958 + } 959 + 960 + @available(macOS 26.0, *) 961 + private func installGlassBackground(matchedTo target: NSView, 962 + below anchor: NSView, 963 + cornerRadius: CGFloat) -> NSView { 964 + let glassView = UnifiedWaveformStripGlassEffectView() 965 + glassView.translatesAutoresizingMaskIntoConstraints = false 966 + glassView.cornerRadius = cornerRadius 967 + addSubview(glassView, positioned: .below, relativeTo: anchor) 968 + NSLayoutConstraint.activate([ 969 + glassView.leadingAnchor.constraint(equalTo: target.leadingAnchor), 970 + glassView.trailingAnchor.constraint(equalTo: target.trailingAnchor), 971 + glassView.topAnchor.constraint(equalTo: target.topAnchor), 972 + glassView.bottomAnchor.constraint(equalTo: target.bottomAnchor), 973 + ]) 974 + return glassView 975 + } 976 + 977 + }
+27 -5
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 61 61 stopLink() 62 62 for i in 0..<levels.count { levels[i] = 0 } 63 63 for i in 0..<displayLevels.count { displayLevels[i] = 0 } 64 - display() // one final paint to clear bars 64 + requestDisplay() // one final paint to clear bars 65 65 } 66 66 } 67 67 } ··· 88 88 dotMatrixActive = true 89 89 // Static frame — nudge a single redraw so the pattern 90 90 // appears even when the live link is off. 91 - display() 91 + requestDisplay() 92 92 } else { 93 93 stopDotMatrix() 94 94 } ··· 99 99 for i in 0..<dotMasks.count { dotMasks[i] = 0 } 100 100 uniforms.dotMatrix = 0 101 101 dotMatrixActive = false 102 - display() 102 + requestDisplay() 103 103 } 104 104 } 105 105 ··· 119 119 // slow frame can build a backlog of stale draw requests. 120 120 guard view.markDisplayPending() else { return kCVReturnSuccess } 121 121 DispatchQueue.main.async { 122 - view.display() 122 + view.requestDisplay() 123 123 view.clearDisplayPending() 124 124 } 125 125 return kCVReturnSuccess ··· 157 157 pendingDisplayLock.unlock() 158 158 } 159 159 160 + private var canDrawSurface: Bool { 161 + guard window?.isVisible == true, 162 + !isHiddenOrHasHiddenAncestor, 163 + bounds.width > 0, 164 + bounds.height > 0 else { 165 + return false 166 + } 167 + return true 168 + } 169 + 170 + private func requestDisplay() { 171 + guard Thread.isMainThread else { 172 + DispatchQueue.main.async { [weak self] in 173 + self?.requestDisplay() 174 + } 175 + return 176 + } 177 + guard canDrawSurface else { return } 178 + display() 179 + } 180 + 160 181 deinit { stopLink() } 161 182 162 183 override func viewDidMoveToWindow() { ··· 318 339 clearColor = MTLClearColor(red: 0.06, green: 0.08, blue: 0.10, alpha: 1.0) 319 340 uniforms.isLight = 0 320 341 } 321 - display() 342 + requestDisplay() 322 343 } 323 344 324 345 func setSurfaceStyle(_ style: SurfaceStyle) { ··· 407 428 func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 408 429 409 430 func draw(in view: MTKView) { 431 + guard canDrawSurface else { return } 410 432 updateLevels() 411 433 412 434 let drawableSize = view.drawableSize