Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

Refine waveform strip and floating palette

- expand menubar waveform strip controls, interactions, and behavior
- split liquid and legacy strip presentation while restoring strip updates
- polish floating palette glass layout and unify collapse behavior with strip controls
- add palette implementation notes for follow-up work

authored by

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

+2376 -173
+108
slab/menuband/PALETTE_LEARNINGS.md
··· 1 + ## Piano / Waveform Palette Learnings 2 + 3 + This file captures the current behavior and implementation constraints for the unified floating/collapsed palette work. 4 + 5 + ### Current architecture 6 + 7 + - `PianoWaveformPalette` is the facade for the two UI states. 8 + - Expanded state is `FloatingPlayPaletteController`. 9 + - Collapsed state is `MenuBarWaveformStrip`. 10 + - `AppDelegate` now treats both as one conceptual element through `pianoWaveformPalette`. 11 + 12 + ### Current state model 13 + 14 + - `PianoWaveformPalette.State.expanded` means the floating palette is the active representation. 15 + - `PianoWaveformPalette.State.collapsed` means the strip is the active representation, even if the strip is currently hidden. 16 + - Hidden collapsed strip is intentional state, not a reset back to expanded. 17 + - In collapsed state, key presses and menubar piano mouse-drag can revive the strip. 18 + - In expanded state, note activity must not auto-show the strip. 19 + 20 + ### Position persistence 21 + 22 + - Expanded palette position is persisted in `FloatingPlayPaletteController` via `UserDefaults`. 23 + - Collapsed strip position is persisted separately in `MenuBarWaveformStrip`. 24 + - Collapsed `Dock` resets the strip back under the menubar piano by clearing the custom origin. 25 + 26 + ### API learnings 27 + 28 + - `NSGlassEffectView` is the primary AppKit Liquid Glass surface for this work. 29 + - `NSGlassEffectView.contentView` is the supported place to embed real content. 30 + - `NSGlassEffectView.cornerRadius` is the direct way to shape the glass surface. 31 + - `NSGlassEffectView.style` can be set to `.clear` for button-like glass chrome. 32 + - `NSGlassEffectView.tintColor` is the supported API for pushing the glass toward the current instrument/accent color. 33 + - `NSGlassEffectContainerView` is useful when several nearby glass views should merge efficiently. 34 + - `NSGlassEffectContainerView.contentView` is the descendant subtree the container manages and merges. 35 + - Apple’s AppKit docs explicitly note that `NSGlassEffectContainerView` can improve performance by batching nearby similar glass views. 36 + - `NSGlassEffectView` only guarantees correct glass behavior for its `contentView`; arbitrary subviews are not guaranteed to behave correctly relative to the glass surface or z-order. 37 + - For fallback styling on older macOS versions, plain layer-backed `NSButton` plus translucent background/border is a practical substitute. 38 + 39 + ### Liquid Glass implementation rules 40 + 41 + - Prefer one larger glass container plus a few intentional child glass surfaces instead of many unrelated decorative glass views. 42 + - Use clear-style glass only for chrome-like controls; use the main strip/palette glass views for the actual body surfaces. 43 + - Keep the overlay buttons and their glass backplates as siblings in the same ancestor view hierarchy. 44 + - Add the interactive button to the hierarchy first, then add and constrain the glass backplate to it. 45 + - If hover-only controls are used, initialize both the button and its glass background to hidden. 46 + - Reapply tinting when the instrument family or MIDI visual mode changes. 47 + 48 + ### Expanded palette controls 49 + 50 + - `FloatingPlayPalette` has hover-only corner controls. 51 + - Left: close. 52 + - Right: dock, expand/collapse. 53 + - On macOS 26+, these use `NSGlassEffectView` with clear-style glass backplates. 54 + - Older macOS versions use a translucent layer-backed fallback. 55 + 56 + ### Collapsed strip controls 57 + 58 + - The strip no longer uses the footer-row controls for close/dock/expand. 59 + - Those controls are now top overlay controls. 60 + - Left: close. 61 + - Right: dock, expand. 62 + - They are intended to be visible only while the pointer is inside the strip. 63 + - Legacy implementation uses layer-backed circular buttons. 64 + - Liquid implementation uses clear `NSGlassEffectView` backplates plus overlay buttons. 65 + 66 + ### Important crash gotcha 67 + 68 + - Do not create glass backplates for buttons before the buttons are attached to the same ancestor view hierarchy. 69 + - We hit an `NSGenericException` from constraints between `StripMovableGlassEffectView` and `NSButton` with no common ancestor. 70 + - Correct order: 71 + 1. add the buttons to `rootView` 72 + 2. then create/install glass backplates constrained to those buttons 73 + 74 + ### Important hover/visibility gotcha 75 + 76 + - Overlay controls must be explicitly reset hidden on strip `show()` and `dismiss()`. 77 + - Relying only on `button.alphaValue = 0` during setup is not enough once the strip has already been shown and hovered before. 78 + - For the liquid strip, glass backplates also need `alphaValue = 0` at creation time. 79 + - Hover state should be treated as transient UI state, not inferred from visibility state. 80 + 81 + ### Symbol visibility gotcha 82 + 83 + - The collapsed strip overlay buttons were effectively invisible when using the weaker default tint assumptions. 84 + - Explicit symbol tinting is safer here. 85 + - Current working tint is `NSColor.white.withAlphaComponent(0.92)`. 86 + 87 + ### Best practices found so far 88 + 89 + - Keep `PianoWaveformPalette` as the only place that understands expanded vs collapsed state transitions. 90 + - Let `AppDelegate` talk to the facade instead of branching on raw strip/palette types. 91 + - Treat “collapsed but hidden” as a real persisted state rather than as a reset. 92 + - Keep note-driven revival behavior gated by palette state, not by global strip availability. 93 + - Preserve origin when resizing floating panels; avoid recentering after every content-size change. 94 + - When moving controls from inline layout to overlay layout, remove their old constraints completely instead of trying to reuse them. 95 + - For hover-only corner controls, separate layout concerns from visibility concerns. 96 + - After crashes involving view hierarchy or constraints, assume later UI errors may be follow-on failures until the first exception is fixed. 97 + 98 + ### Behavior intentionally disabled 99 + 100 + - The old independent menubar waveform-strip behavior is no longer the primary model. 101 + - The strip still exists, but it should behave as the collapsed state of `PianoWaveformPalette`. 102 + - Do not reintroduce unconditional note-driven strip showing unless that behavior is explicitly wanted again. 103 + 104 + ### Future work areas 105 + 106 + - Wire the collapsed `Dock` button to any broader palette-level docking semantics if the expanded state should also understand docking. 107 + - Consider whether collapsed-state `Close` should remain hidden-state collapsed, or switch the conceptual state elsewhere. 108 + - If hover-only controls still appear immediately on show, investigate initial tracking-area / pointer-enter timing rather than button styling.
+61 -14
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 floatingPlayPalette = FloatingPlayPaletteController(menuBand: menuBand) 17 - private lazy var waveformStrip = MenuBarWaveformStrip(menuBand: menuBand) 16 + private lazy var pianoWaveformPalette = PianoWaveformPalette(menuBand: menuBand) 17 + private var floatingPlayPalette: PianoWaveformPalette { pianoWaveformPalette } 18 + private var waveformStrip: PianoWaveformPalette { pianoWaveformPalette } 18 19 private var appBeforePopover: NSRunningApplication? 19 20 private var appBeforeFocusCapture: NSRunningApplication? 20 21 private var focusCaptureArmedByShortcut = false ··· 171 172 self.updateIcon() 172 173 self.popoverVC?.refreshHeldNotes() 173 174 self.floatingPlayPalette.refresh() 174 - // strip retired — no-op 175 + self.waveformStrip.refreshAppearance() 175 176 self.updateWaveformStrip() 176 177 } 177 178 menuBand.onInstrumentVisualChange = { [weak self] in ··· 179 180 guard let self = self else { return } 180 181 self.updateIcon() 181 182 self.floatingPlayPalette.refresh() 182 - // strip retired — no-op 183 + self.waveformStrip.refreshAppearance() 183 184 } 184 185 } 185 186 menuBand.bootstrap() 187 + lastKnownOctaveShift = menuBand.octaveShift 186 188 floatingPlayPalette.onDismiss = { [weak self] in 187 189 self?.updateIcon() 188 190 self?.popoverVC?.syncFromController() ··· 225 227 } 226 228 updateIcon() 227 229 228 - // Strip retired — no warmup needed. 230 + // Pre-build the waveform strip panel so the first note press 231 + // doesn't stall on panel + Metal pipeline construction. 232 + waveformStrip.reposition(statusItemButton: statusItem.button) 233 + waveformStrip.onStepBackward = { [weak self] in 234 + self?.menuBand.stepMelodicProgram(delta: -1) 235 + } 236 + waveformStrip.onStepForward = { [weak self] in 237 + self?.menuBand.stepMelodicProgram(delta: +1) 238 + } 239 + waveformStrip.onStepUp = { [weak self] in 240 + self?.menuBand.stepMelodicProgram(delta: -InstrumentListView.cols) 241 + } 242 + waveformStrip.onStepDown = { [weak self] in 243 + self?.menuBand.stepMelodicProgram(delta: +InstrumentListView.cols) 244 + } 245 + waveformStrip.warmUp() 229 246 230 247 registerTypeModeHotkey() 231 248 _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) ··· 262 279 ) { 263 280 self.toggleKeyboardLayoutShortcut() 264 281 return true 282 + } 283 + if self.popover.isShown == false && self.floatingPlayPalette.isShown == false { 284 + switch keyCode { 285 + case 123: // kVK_LeftArrow 286 + if isDown { 287 + self.waveformStrip.registerArrowInput() 288 + if !isRepeat { self.menuBand.stepMelodicProgram(delta: -1) } 289 + } 290 + return true 291 + case 124: // kVK_RightArrow 292 + if isDown { 293 + self.waveformStrip.registerArrowInput() 294 + if !isRepeat { self.menuBand.stepMelodicProgram(delta: +1) } 295 + } 296 + return true 297 + case 125: // kVK_DownArrow 298 + if isDown { 299 + self.waveformStrip.registerArrowInput() 300 + if !isRepeat { self.menuBand.stepMelodicProgram(delta: +InstrumentListView.cols) } 301 + } 302 + return true 303 + case 126: // kVK_UpArrow 304 + if isDown { 305 + self.waveformStrip.registerArrowInput() 306 + if !isRepeat { self.menuBand.stepMelodicProgram(delta: -InstrumentListView.cols) } 307 + } 308 + return true 309 + default: 310 + break 311 + } 265 312 } 266 313 let consumed = self.menuBand.handleLocalKey( 267 314 keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags ··· 1162 1209 let initialShift = downEvent.modifierFlags.contains(.shift) 1163 1210 || downEvent.modifierFlags.contains(.capsLock) 1164 1211 menuBand.startTapNote(startNote, velocity: vel0, pan: pan0, linger: initialShift) 1212 + waveformStrip.showIfNeeded() 1165 1213 // Arm sandbox-friendly local capture on a real piano click. We 1166 1214 // skip arming when global TYPE mode is already on — the global 1167 1215 // tap is already handling keys, doubling up would re-trigger ··· 1195 1243 let shiftNow = next.modifierFlags.contains(.shift) 1196 1244 || next.modifierFlags.contains(.capsLock) 1197 1245 menuBand.startTapNote(nxt, velocity: v, pan: p, linger: shiftNow) 1246 + waveformStrip.showIfNeeded() 1198 1247 } 1199 1248 current = hovered 1200 1249 } else if let c = current { ··· 1295 1344 // MARK: - Menubar waveform strip 1296 1345 1297 1346 private func updateWaveformStrip() { 1298 - // Strip retired — the menubar mini meter replaces it. We keep 1299 - // this method as a no-op so the call sites elsewhere stay 1300 - // wired without each one having to know about the retirement. 1347 + guard waveformStrip.isCollapsedState else { return } 1348 + if !menuBand.litNotes.isEmpty { 1349 + waveformStrip.showIfNeeded() 1350 + } else { 1351 + waveformStrip.scheduleHide() 1352 + } 1301 1353 } 1302 1354 1303 1355 private func updateWaveformStripSuppression() { 1304 - // Strip retired. The mini meter now stays animating at all 1305 - // times (including when the popover or palette is open) so 1306 - // the menubar icon always feels "live." When silent, the 1307 - // bars fall to a short floor instead of disappearing — see 1308 - // KeyboardIconRenderer.drawChipVisualizer for the silent- 1309 - // floor handling. This method is intentionally a no-op now. 1356 + waveformStrip.suppressed = waveformStrip.isDocked && (popover.isShown || floatingPlayPalette.isShown) 1310 1357 } 1311 1358 }
+45 -14
slab/menuband/Sources/MenuBand/ArrowKeysIndicator.swift
··· 9 9 /// Direction indices match `InstrumentMapView.onArrowKey`: 10 10 /// 0 = ← 1 = → 2 = ↓ 3 = ↑ 11 11 final class ArrowKeysIndicator: NSView { 12 + enum DisplayMode { 13 + case cluster 14 + case horizontalPair 15 + } 16 + 12 17 private var pressed: Set<Int> = [] 13 18 private var hovered: Int? 14 19 private var trackingArea: NSTrackingArea? ··· 20 25 /// release). 21 26 var onClick: ((Int, Bool) -> Void)? 22 27 23 - static let intrinsicSize = NSSize(width: 46, height: 30) 24 - override var intrinsicContentSize: NSSize { Self.intrinsicSize } 28 + var displayMode: DisplayMode = .cluster { 29 + didSet { 30 + invalidateIntrinsicContentSize() 31 + needsDisplay = true 32 + } 33 + } 34 + 35 + private static let clusterIntrinsicSize = NSSize(width: 46, height: 30) 36 + private static let horizontalPairIntrinsicSize = NSSize(width: 29, height: 15) 37 + override var intrinsicContentSize: NSSize { 38 + switch displayMode { 39 + case .cluster: 40 + return Self.clusterIntrinsicSize 41 + case .horizontalPair: 42 + return Self.horizontalPairIntrinsicSize 43 + } 44 + } 25 45 26 46 override init(frame frameRect: NSRect) { 27 47 super.init(frame: frameRect) 28 - setFrameSize(Self.intrinsicSize) 48 + setFrameSize(Self.clusterIntrinsicSize) 29 49 } 30 50 required init?(coder: NSCoder) { fatalError() } 31 51 ··· 54 74 55 75 /// Compute the four keycap rects in the same layout as `draw`. 56 76 /// Returned in direction index order: 0=←, 1=→, 2=↓, 3=↑. 57 - private func keyRects() -> [NSRect] { 77 + private func keyRects() -> [(direction: Int, rect: NSRect)] { 58 78 let r = bounds 59 79 let key: CGFloat = 13 60 80 let gap: CGFloat = 1 61 - let centerX = r.midX 62 81 let bottomY = r.minY + 1 63 - let topY = bottomY + key + gap 64 - let downRect = NSRect(x: centerX - key / 2, y: bottomY, width: key, height: key) 65 - let leftRect = NSRect(x: downRect.minX - key - gap, y: bottomY, width: key, height: key) 66 - let rightRect = NSRect(x: downRect.maxX + gap, y: bottomY, width: key, height: key) 67 - let upRect = NSRect(x: downRect.minX, y: topY, width: key, height: key) 68 - return [leftRect, rightRect, downRect, upRect] 82 + switch displayMode { 83 + case .cluster: 84 + let centerX = r.midX 85 + let topY = bottomY + key + gap 86 + let downRect = NSRect(x: centerX - key / 2, y: bottomY, width: key, height: key) 87 + let leftRect = NSRect(x: downRect.minX - key - gap, y: bottomY, width: key, height: key) 88 + let rightRect = NSRect(x: downRect.maxX + gap, y: bottomY, width: key, height: key) 89 + let upRect = NSRect(x: downRect.minX, y: topY, width: key, height: key) 90 + return [(0, leftRect), (1, rightRect), (2, downRect), (3, upRect)] 91 + case .horizontalPair: 92 + let pairWidth = key * 2 + gap 93 + let startX = r.midX - pairWidth / 2 94 + let leftRect = NSRect(x: startX, y: bottomY, width: key, height: key) 95 + let rightRect = NSRect(x: leftRect.maxX + gap, y: bottomY, width: key, height: key) 96 + return [(0, leftRect), (1, rightRect)] 97 + } 69 98 } 70 99 71 100 private func direction(at point: NSPoint) -> Int? { 72 - for (idx, kr) in keyRects().enumerated() where kr.contains(point) { 73 - return idx 101 + for item in keyRects() where item.rect.contains(point) { 102 + return item.direction 74 103 } 75 104 return nil 76 105 } ··· 114 143 super.draw(dirtyRect) 115 144 let radius: CGFloat = 2.5 116 145 let glyphs = ["←", "→", "↓", "↑"] 117 - for (idx, kr) in keyRects().enumerated() { 146 + for item in keyRects() { 147 + let idx = item.direction 148 + let kr = item.rect 118 149 let lit = pressed.contains(idx) 119 150 let isHover = (!lit && hovered == idx) 120 151 let path = NSBezierPath(roundedRect: kr, xRadius: radius, yRadius: radius)
+457 -76
slab/menuband/Sources/MenuBand/FloatingPlayPalette.swift
··· 1 1 import AppKit 2 2 3 + private enum FloatingPaletteVisualStyleOverride: 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 + @available(macOS 26.0, *) 21 + private final class FloatingPaletteGlassEffectView: NSGlassEffectView { 22 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 23 + } 24 + 3 25 final class FloatingPlayPaletteController: NSObject, NSWindowDelegate { 4 26 enum DismissReason { 5 27 case closeButton ··· 22 44 private var keyMonitor: Any? 23 45 private var appBeforeOpen: NSRunningApplication? 24 46 private var isDismissing = false 47 + private var savedOrigin: NSPoint? 48 + 49 + private static let customOriginXKey = "notepat.floatingPlayPalette.customOriginX" 50 + private static let customOriginYKey = "notepat.floatingPlayPalette.customOriginY" 25 51 26 52 var onDismiss: (() -> Void)? 27 53 var onFocusRelease: (() -> Void)? 28 54 var onToggleKeymap: (() -> Void)? 55 + var onExpandCollapseToggle: (() -> Void)? { 56 + get { viewController.onExpandCollapseToggle } 57 + set { viewController.onExpandCollapseToggle = newValue } 58 + } 29 59 var isPianoFocusActive: (() -> Bool)? { 30 60 get { viewController.isPianoFocusActive } 31 61 set { viewController.isPianoFocusActive = newValue } ··· 38 68 init(menuBand: MenuBandController) { 39 69 self.menuBand = menuBand 40 70 self.viewController = FloatingPlayPaletteViewController(menuBand: menuBand) 71 + self.savedOrigin = Self.loadSavedOrigin() 41 72 super.init() 42 73 self.viewController.onClose = { [weak self] in 43 74 self?.dismiss(reason: .closeButton) ··· 67 98 68 99 appBeforeOpen = previousApp ?? currentFrontmostOtherApp() 69 100 viewController.refresh() 70 - panel.setFrame(frameForCurrentMouseScreen(size: viewController.preferredContentSize), display: false) 101 + panel.setFrame(restoredFrame(size: viewController.preferredContentSize), display: false) 71 102 72 103 NSApp.activate(ignoringOtherApps: true) 73 104 panel.makeKeyAndOrderFront(nil) ··· 115 146 private func buildPanel() { 116 147 let p = FloatingPlayPalettePanel( 117 148 contentRect: NSRect(origin: .zero, size: viewController.preferredContentSize), 118 - styleMask: [.borderless], 149 + styleMask: [.titled, .closable, .fullSizeContentView], 119 150 backing: .buffered, 120 151 defer: false 121 152 ) ··· 129 160 p.collectionBehavior = [.transient] 130 161 p.hidesOnDeactivate = false 131 162 p.canHide = false 132 - p.isMovableByWindowBackground = false 163 + p.isMovableByWindowBackground = true 133 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 170 + } 171 + if let mini = p.standardWindowButton(.miniaturizeButton) { 172 + mini.isHidden = true 173 + } 174 + if let zoom = p.standardWindowButton(.zoomButton) { 175 + zoom.isHidden = true 176 + } 134 177 panel = p 135 178 } 136 179 ··· 178 221 let size = viewController.preferredContentSize 179 222 let old = panel.frame 180 223 guard abs(old.width - size.width) > 0.5 || abs(old.height - size.height) > 0.5 else { return } 181 - let center = NSPoint(x: old.midX, y: old.midY) 182 224 var frame = NSRect( 183 - x: center.x - size.width / 2, 184 - y: center.y - size.height / 2, 225 + x: old.origin.x, 226 + y: old.origin.y, 185 227 width: size.width, 186 228 height: size.height 187 229 ) ··· 189 231 ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 190 232 frame = clamped(frame, to: visible) 191 233 panel.setFrame(frame, display: true) 234 + persistPanelOrigin() 192 235 } 193 236 194 237 private func frameForCurrentMouseScreen(size: NSSize) -> NSRect { ··· 207 250 return NSRect(origin: NSPoint(x: x, y: y), size: size) 208 251 } 209 252 253 + private func restoredFrame(size: NSSize) -> NSRect { 254 + guard let savedOrigin else { 255 + return frameForCurrentMouseScreen(size: size) 256 + } 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 + } 263 + 210 264 private func clamped(_ frame: NSRect, to visible: NSRect) -> NSRect { 211 265 let margin: CGFloat = 16 212 266 let x = min(max(frame.origin.x, visible.minX + margin), visible.maxX - frame.width - margin) ··· 227 281 app.activate(options: [.activateIgnoringOtherApps]) 228 282 } 229 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) 294 + } 295 + 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) 303 + ) 304 + } 305 + 230 306 deinit { 231 307 dismiss(reason: .programmatic) 232 308 } ··· 238 314 get { paletteView.onClose } 239 315 set { paletteView.onClose = newValue } 240 316 } 317 + var onExpandCollapseToggle: (() -> Void)? { 318 + get { paletteView.onExpandCollapseToggle } 319 + set { paletteView.onExpandCollapseToggle = newValue } 320 + } 241 321 var isPianoFocusActive: (() -> Bool)? { 242 322 get { paletteView.isPianoFocusActive } 243 323 set { paletteView.isPianoFocusActive = newValue } 324 + } 325 + var onHoverChanged: ((Bool) -> Void)? { 326 + get { paletteView.onHoverChanged } 327 + set { paletteView.onHoverChanged = newValue } 244 328 } 245 329 246 330 init(menuBand: MenuBandController) { ··· 273 357 } 274 358 275 359 private final class FloatingPlayPaletteView: NSView { 360 + private static let defaultsDomain = "computer.aestheticcomputer.menuband" 361 + private static let styleOverrideDefaultsKey = "MenuBandFloatingPlayPaletteStyle" 362 + private static let styleOverrideEnvironmentKey = "MENUBAND_FLOATING_PLAY_PALETTE_STYLE" 363 + 364 + private static var visualStyleOverride: FloatingPaletteVisualStyleOverride { 365 + let environmentValue = ProcessInfo.processInfo.environment[styleOverrideEnvironmentKey] 366 + if environmentValue != nil { 367 + return FloatingPaletteVisualStyleOverride(rawValue: environmentValue) 368 + } 369 + let defaultsValue = UserDefaults(suiteName: defaultsDomain)?.string(forKey: styleOverrideDefaultsKey) 370 + ?? UserDefaults.standard.string(forKey: styleOverrideDefaultsKey) 371 + return FloatingPaletteVisualStyleOverride(rawValue: defaultsValue) 372 + } 373 + 374 + private static var shouldUseLiquidGlass: Bool { 375 + switch visualStyleOverride { 376 + case .liquid: 377 + if #available(macOS 26.0, *) { return true } 378 + return false 379 + case .legacy: 380 + return false 381 + case .automatic: 382 + if #available(macOS 26.0, *) { return true } 383 + return false 384 + } 385 + } 386 + 276 387 private weak var menuBand: MenuBandController? 388 + private let contentStack = NSStackView() 389 + private let waveformSection = NSView() 277 390 private let waveformView = WaveformView() 278 391 private let waveformBezel = NSView() 392 + private let waveformClipView = NSView() 279 393 private let heldNotesStack = NSStackView() 280 394 private let heldNotesRow = NSView() 281 395 private let chordCandidatesStack = NSStackView() ··· 284 398 private let instrumentReadout = NSTextField(labelWithString: "") 285 399 private let instrumentTitleRow: NSStackView 286 400 private let pianoView: FloatingPianoView 287 - private let dragHandle = FloatingPaletteDragHandleView() 288 401 private let closeButton = NSButton() 289 - private let shortcutHintLabel = NSTextField(labelWithString: "") 402 + private let dockButton = NSButton() 403 + private let expandCollapseButton = NSButton() 404 + private let shortcutHintRow = NSStackView() 405 + private let focusHintLabel = NSTextField(labelWithString: "") 406 + private let layoutHintLabel = NSTextField(labelWithString: "") 407 + private weak var closeButtonGlassView: NSView? 408 + private weak var dockButtonGlassView: NSView? 409 + private weak var expandCollapseButtonGlassView: NSView? 410 + private weak var paletteGlassView: NSView? 411 + private weak var waveformGlassView: NSView? 412 + private weak var waveformSectionGlassView: NSView? 413 + private weak var pianoGlassView: NSView? 414 + private weak var keymapGlassView: NSView? 415 + private weak var hintGlassView: NSView? 290 416 /// Large QWERTY keymap shown beneath the piano so the user can 291 417 /// see at a glance which physical keys play which notes. Driven 292 418 /// at 2× scale so it's legible at the floating palette's size. 293 419 private let qwertyView = QwertyLayoutView() 294 420 295 421 var onClose: (() -> Void)? 422 + var onExpandCollapseToggle: (() -> Void)? 296 423 var isPianoFocusActive: (() -> Bool)? 424 + var onHoverChanged: ((Bool) -> Void)? 297 425 298 426 private let pianoScale: CGFloat = 1.6 299 427 private let inset: CGFloat = 14 300 428 private let gap: CGFloat = 8 301 - private let closeSize: CGFloat = 18 302 - private let hintHeight: CGFloat = 42 429 + private let closeButtonSize: CGFloat = 30 430 + private let closeButtonCornerInset: CGFloat = 3 431 + private let hintHeight: CGFloat = 20 303 432 private let heldNotesRowHeight: CGFloat = 26 304 433 private let chordCandidatesRowHeight: CGFloat = 30 305 434 private var waveformHeightConstraint: NSLayoutConstraint? 435 + private var trackingArea: NSTrackingArea? 436 + private var isExpanded = true 437 + private static let panelCornerRadius: CGFloat = 18 438 + private static let sectionCornerRadius: CGFloat = 14 439 + private static let waveformClipCornerRadius: CGFloat = 12 306 440 307 441 init(menuBand: MenuBandController) { 308 442 self.menuBand = menuBand ··· 317 451 318 452 waveformView.menuBand = menuBand 319 453 waveformView.translatesAutoresizingMaskIntoConstraints = false 454 + waveformView.setSurfaceStyle(.glassEmbedded) 455 + contentStack.orientation = .vertical 456 + contentStack.alignment = .centerX 457 + contentStack.distribution = .fill 458 + contentStack.spacing = gap 459 + contentStack.translatesAutoresizingMaskIntoConstraints = false 460 + waveformSection.wantsLayer = true 461 + waveformSection.layer?.cornerRadius = Self.sectionCornerRadius 462 + waveformSection.layer?.borderWidth = 0.8 463 + if #available(macOS 10.15, *) { 464 + waveformSection.layer?.cornerCurve = .continuous 465 + } 466 + waveformSection.translatesAutoresizingMaskIntoConstraints = false 320 467 waveformBezel.wantsLayer = true 321 - waveformBezel.layer?.cornerRadius = 6 322 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 323 - waveformBezel.layer?.borderWidth = 1 468 + waveformBezel.layer?.cornerRadius = Self.waveformClipCornerRadius + 2 469 + waveformBezel.layer?.borderWidth = 0 470 + if #available(macOS 10.15, *) { 471 + waveformBezel.layer?.cornerCurve = .continuous 472 + } 324 473 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 474 + waveformClipView.wantsLayer = true 475 + waveformClipView.translatesAutoresizingMaskIntoConstraints = false 476 + waveformClipView.layer?.cornerRadius = Self.waveformClipCornerRadius 477 + waveformClipView.layer?.masksToBounds = true 478 + if #available(macOS 10.15, *) { 479 + waveformClipView.layer?.cornerCurve = .continuous 480 + } 325 481 heldNotesStack.orientation = .horizontal 326 482 heldNotesStack.alignment = .centerY 327 483 heldNotesStack.spacing = 6 ··· 344 500 instrumentTitleRow.spacing = 0 345 501 instrumentTitleRow.translatesAutoresizingMaskIntoConstraints = false 346 502 pianoView.translatesAutoresizingMaskIntoConstraints = false 347 - dragHandle.translatesAutoresizingMaskIntoConstraints = false 348 503 closeButton.translatesAutoresizingMaskIntoConstraints = false 349 - shortcutHintLabel.translatesAutoresizingMaskIntoConstraints = false 504 + dockButton.translatesAutoresizingMaskIntoConstraints = false 505 + expandCollapseButton.translatesAutoresizingMaskIntoConstraints = false 506 + shortcutHintRow.orientation = .horizontal 507 + shortcutHintRow.alignment = .centerY 508 + shortcutHintRow.distribution = .fill 509 + shortcutHintRow.spacing = gap 510 + shortcutHintRow.translatesAutoresizingMaskIntoConstraints = false 511 + focusHintLabel.translatesAutoresizingMaskIntoConstraints = false 512 + layoutHintLabel.translatesAutoresizingMaskIntoConstraints = false 350 513 qwertyView.scale = 1.4 351 514 qwertyView.keymap = menuBand.keymap 352 515 qwertyView.translatesAutoresizingMaskIntoConstraints = false ··· 364 527 // for the chord readout. Stack vertically: pills on top, cards 365 528 // beneath, both centered. Waveform draws underneath, peeking 366 529 // through the translucent card backgrounds. 367 - waveformBezel.addSubview(waveformView) 530 + waveformBezel.addSubview(waveformClipView) 531 + waveformClipView.addSubview(waveformView) 368 532 waveformBezel.layer?.masksToBounds = false 369 533 heldNotesRow.wantsLayer = true 370 534 heldNotesRow.layer?.masksToBounds = false ··· 372 536 chordCandidatesRow.layer?.masksToBounds = false 373 537 waveformBezel.addSubview(heldNotesRow) 374 538 waveformBezel.addSubview(chordCandidatesRow) 375 - addSubview(waveformBezel) 376 - addSubview(instrumentTitleRow) 377 - addSubview(pianoView) 378 - addSubview(qwertyView) 379 - shortcutHintLabel.font = NSFont.systemFont(ofSize: 10) 380 - shortcutHintLabel.textColor = .secondaryLabelColor 381 - shortcutHintLabel.alignment = .center 382 - shortcutHintLabel.maximumNumberOfLines = 2 383 - shortcutHintLabel.lineBreakMode = .byWordWrapping 384 - addSubview(shortcutHintLabel) 385 - addSubview(dragHandle) 539 + waveformSection.addSubview(waveformBezel) 540 + waveformSection.addSubview(instrumentTitleRow) 541 + addSubview(contentStack) 542 + contentStack.addArrangedSubview(waveformSection) 543 + contentStack.addArrangedSubview(pianoView) 544 + shortcutHintRow.addArrangedSubview(layoutHintLabel) 545 + shortcutHintRow.addArrangedSubview(NSView()) 546 + shortcutHintRow.addArrangedSubview(focusHintLabel) 547 + contentStack.addArrangedSubview(shortcutHintRow) 548 + contentStack.addArrangedSubview(qwertyView) 549 + for label in [focusHintLabel, layoutHintLabel] { 550 + label.font = NSFont.systemFont(ofSize: 10, weight: .bold) 551 + label.textColor = .secondaryLabelColor 552 + label.maximumNumberOfLines = 1 553 + label.lineBreakMode = .byTruncatingTail 554 + } 555 + layoutHintLabel.alignment = .left 556 + focusHintLabel.alignment = .right 386 557 updateShortcutHint() 387 558 388 - let closeConfig = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 389 - closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? 390 - .withSymbolConfiguration(closeConfig) 391 - closeButton.isBordered = false 392 - closeButton.imagePosition = .imageOnly 393 - closeButton.contentTintColor = .secondaryLabelColor 394 - closeButton.toolTip = "Close" 395 - closeButton.target = self 396 - closeButton.action = #selector(closeClicked(_:)) 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() 397 578 addSubview(closeButton) 579 + addSubview(dockButton) 580 + addSubview(expandCollapseButton) 581 + installLiquidGlassBackgrounds() 398 582 399 583 let keyboardSize = self.keyboardSize() 400 584 let waveformHeightConstraint = waveformView.heightAnchor.constraint( ··· 407 591 NSLayoutConstraint.activate([ 408 592 widthAnchor.constraint(equalToConstant: keyboardSize.width + inset * 2), 409 593 410 - closeButton.topAnchor.constraint(equalTo: topAnchor, constant: inset), 411 - closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 412 - closeButton.widthAnchor.constraint(equalToConstant: closeSize), 413 - closeButton.heightAnchor.constraint(equalToConstant: closeSize), 594 + contentStack.topAnchor.constraint(equalTo: topAnchor, constant: inset), 595 + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 596 + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 597 + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), 598 + 599 + waveformSection.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 600 + waveformSection.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 601 + 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), 414 612 415 - dragHandle.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 416 - dragHandle.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -gap), 417 - dragHandle.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), 418 - dragHandle.heightAnchor.constraint(equalToConstant: closeSize), 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), 419 625 420 626 heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesRow.centerXAnchor), 421 627 heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesRow.centerYAnchor), ··· 429 635 chordCandidatesStack.trailingAnchor.constraint(lessThanOrEqualTo: chordCandidatesRow.trailingAnchor, constant: -6), 430 636 chordCandidatesRow.heightAnchor.constraint(equalToConstant: chordCandidatesRowHeight), 431 637 432 - waveformBezel.topAnchor.constraint(equalTo: closeButton.bottomAnchor, constant: gap), 433 - waveformBezel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 434 - waveformBezel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 435 - waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 436 - waveformView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 437 - waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 438 - waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 638 + waveformBezel.topAnchor.constraint(equalTo: waveformSection.topAnchor, constant: bezelInset), 639 + waveformBezel.leadingAnchor.constraint(equalTo: waveformSection.leadingAnchor, constant: bezelInset), 640 + waveformBezel.trailingAnchor.constraint(equalTo: waveformSection.trailingAnchor, constant: -bezelInset), 641 + waveformClipView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 642 + waveformClipView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 643 + waveformClipView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 644 + waveformClipView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 645 + waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 646 + waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 647 + waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 648 + waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 439 649 waveformHeightConstraint, 440 650 441 651 heldNotesRow.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), ··· 446 656 chordCandidatesRow.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 447 657 chordCandidatesRow.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -8), 448 658 449 - instrumentTitleRow.topAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: gap), 450 - instrumentTitleRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 451 - instrumentTitleRow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 659 + instrumentTitleRow.topAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: 6), 660 + instrumentTitleRow.leadingAnchor.constraint(equalTo: waveformSection.leadingAnchor, constant: 6), 661 + instrumentTitleRow.trailingAnchor.constraint(equalTo: waveformSection.trailingAnchor, constant: -6), 662 + instrumentTitleRow.bottomAnchor.constraint(equalTo: waveformSection.bottomAnchor, constant: -6), 663 + 664 + pianoView.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 665 + pianoView.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 452 666 453 - pianoView.topAnchor.constraint(equalTo: instrumentTitleRow.bottomAnchor, constant: gap), 454 - pianoView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 455 - pianoView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 667 + shortcutHintRow.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 668 + shortcutHintRow.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 669 + shortcutHintRow.heightAnchor.constraint(equalToConstant: hintHeight), 456 670 457 - qwertyView.topAnchor.constraint(equalTo: pianoView.bottomAnchor, constant: gap), 458 671 qwertyView.centerXAnchor.constraint(equalTo: centerXAnchor), 459 672 qwertyView.widthAnchor.constraint( 460 673 equalToConstant: QwertyLayoutView.intrinsicSize.width * 1.4 ··· 462 675 qwertyView.heightAnchor.constraint( 463 676 equalToConstant: QwertyLayoutView.intrinsicSize.height * 1.4 464 677 ), 465 - 466 - shortcutHintLabel.topAnchor.constraint(equalTo: qwertyView.bottomAnchor, constant: gap), 467 - shortcutHintLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 468 - shortcutHintLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 469 - shortcutHintLabel.heightAnchor.constraint(equalToConstant: hintHeight), 470 - shortcutHintLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset) 471 678 ]) 472 679 if titleSpacers.count == 3 { 473 680 titleSpacers[0].widthAnchor.constraint(equalTo: titleSpacers[2].widthAnchor).isActive = true ··· 479 686 nil 480 687 } 481 688 689 + private func installLiquidGlassBackgrounds() { 690 + guard Self.shouldUseLiquidGlass, #available(macOS 26.0, *) else { return } 691 + 692 + let paletteGlassView = FloatingPaletteGlassEffectView() 693 + paletteGlassView.translatesAutoresizingMaskIntoConstraints = false 694 + paletteGlassView.cornerRadius = Self.panelCornerRadius 695 + addSubview(paletteGlassView, positioned: .below, relativeTo: waveformSection) 696 + NSLayoutConstraint.activate([ 697 + paletteGlassView.leadingAnchor.constraint(equalTo: leadingAnchor), 698 + paletteGlassView.trailingAnchor.constraint(equalTo: trailingAnchor), 699 + paletteGlassView.topAnchor.constraint(equalTo: topAnchor), 700 + paletteGlassView.bottomAnchor.constraint(equalTo: bottomAnchor), 701 + ]) 702 + self.paletteGlassView = paletteGlassView 703 + 704 + self.waveformSectionGlassView = installGlassBackground( 705 + matchedTo: waveformSection, 706 + below: waveformSection, 707 + cornerRadius: Self.sectionCornerRadius 708 + ) 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 + self.pianoGlassView = installGlassBackground( 725 + matchedTo: pianoView, 726 + below: pianoView, 727 + cornerRadius: Self.sectionCornerRadius 728 + ) 729 + self.keymapGlassView = installGlassBackground( 730 + matchedTo: qwertyView, 731 + below: qwertyView, 732 + cornerRadius: Self.sectionCornerRadius 733 + ) 734 + self.hintGlassView = installGlassBackground( 735 + matchedTo: shortcutHintRow, 736 + below: shortcutHintRow, 737 + cornerRadius: Self.sectionCornerRadius 738 + ) 739 + } 740 + 741 + @available(macOS 26.0, *) 742 + private func installGlassBackground(matchedTo target: NSView, 743 + below anchor: NSView, 744 + cornerRadius: CGFloat) -> NSView { 745 + let glassView = FloatingPaletteGlassEffectView() 746 + glassView.translatesAutoresizingMaskIntoConstraints = false 747 + glassView.cornerRadius = cornerRadius 748 + addSubview(glassView, positioned: .below, relativeTo: anchor) 749 + NSLayoutConstraint.activate([ 750 + glassView.leadingAnchor.constraint(equalTo: target.leadingAnchor), 751 + glassView.trailingAnchor.constraint(equalTo: target.trailingAnchor), 752 + glassView.topAnchor.constraint(equalTo: target.topAnchor), 753 + glassView.bottomAnchor.constraint(equalTo: target.bottomAnchor), 754 + ]) 755 + return glassView 756 + } 757 + 482 758 override var acceptsFirstResponder: Bool { true } 483 759 760 + override func updateTrackingAreas() { 761 + super.updateTrackingAreas() 762 + if let trackingArea { 763 + removeTrackingArea(trackingArea) 764 + } 765 + let trackingArea = NSTrackingArea( 766 + rect: bounds, 767 + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], 768 + owner: self, 769 + userInfo: nil 770 + ) 771 + addTrackingArea(trackingArea) 772 + self.trackingArea = trackingArea 773 + } 774 + 775 + override func mouseEntered(with event: NSEvent) { 776 + super.mouseEntered(with: event) 777 + setCloseControlVisible(true) 778 + onHoverChanged?(true) 779 + } 780 + 781 + override func mouseExited(with event: NSEvent) { 782 + super.mouseExited(with: event) 783 + setCloseControlVisible(false) 784 + onHoverChanged?(false) 785 + } 786 + 484 787 override func viewDidChangeEffectiveAppearance() { 485 788 super.viewDidChangeEffectiveAppearance() 486 789 applyAppearanceToVisualizer() ··· 494 797 495 798 override func draw(_ dirtyRect: NSRect) { 496 799 super.draw(dirtyRect) 800 + if Self.shouldUseLiquidGlass, #available(macOS 26.0, *) { 801 + return 802 + } 497 803 let background = bounds.insetBy(dx: 0.5, dy: 0.5) 498 804 let path = NSBezierPath(roundedRect: background, xRadius: 13, yRadius: 13) 499 805 NSColor.windowBackgroundColor.withAlphaComponent(0.96).setFill() ··· 534 840 waveformView.alphaValue = (menuBand?.midiMode ?? false) ? 0.35 : 1.0 535 841 } 536 842 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 + 537 856 private func applyAppearanceToVisualizer() { 538 857 let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 539 858 waveformView.setLightMode(!isDark) 540 859 if isDark { 541 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 860 + waveformSection.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor 861 + waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.04).cgColor 542 862 } else { 543 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.82, alpha: 1.0).cgColor 863 + waveformSection.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.22).cgColor 864 + waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor 544 865 } 545 866 } 546 867 547 868 private func applyWaveformTint() { 548 869 guard let menuBand else { return } 870 + let paletteTint: NSColor 549 871 if menuBand.midiMode { 550 872 waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 551 873 waveformView.setBaseColor(.controlAccentColor) 552 - waveformBezel.layer?.borderColor = NSColor.controlAccentColor 553 - .withAlphaComponent(0.55).cgColor 874 + waveformSection.layer?.borderColor = NSColor.controlAccentColor 875 + .withAlphaComponent(0.24).cgColor 876 + paletteTint = NSColor.controlAccentColor.withAlphaComponent(0.20) 554 877 } else { 555 878 waveformView.setDotMatrix(nil) 556 879 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 557 880 let familyColor = InstrumentListView.colorForProgram(safe) 558 881 waveformView.setBaseColor(familyColor) 559 - waveformBezel.layer?.borderColor = familyColor 560 - .withAlphaComponent(0.55).cgColor 882 + waveformSection.layer?.borderColor = familyColor 883 + .withAlphaComponent(0.22).cgColor 884 + paletteTint = familyColor.withAlphaComponent(0.16) 885 + } 886 + if #available(macOS 26.0, *) { 887 + for view in [paletteGlassView, waveformSectionGlassView, pianoGlassView, keymapGlassView, hintGlassView] { 888 + (view as? NSGlassEffectView)?.tintColor = paletteTint 889 + } 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 + } 561 905 } 562 906 } 563 907 ··· 693 1037 } 694 1038 695 1039 private func updateShortcutHint() { 696 - let floatingShortcut = MenuBandShortcutPreferences.playPaletteShortcut.displayString 697 1040 let focusShortcut = MenuBandShortcutPreferences.focusShortcut.displayString 698 1041 let layoutShortcut = MenuBandShortcut.layoutToggle.displayString 699 - let focusText = (isPianoFocusActive?() ?? false) 700 - ? "Exit focus: \(focusShortcut)" 701 - : "Focus piano: \(focusShortcut)" 702 - shortcutHintLabel.stringValue = 703 - "Show/hide floating piano: \(floatingShortcut)\n\(focusText) Toggle layout: \(layoutShortcut)" 1042 + layoutHintLabel.stringValue = "Toggle Layout: \(layoutShortcut)" 1043 + focusHintLabel.stringValue = (isPianoFocusActive?() ?? false) 1044 + ? "Exit Focus: \(focusShortcut)" 1045 + : "Focus Piano: \(focusShortcut)" 704 1046 } 705 1047 706 1048 @objc private func closeClicked(_ sender: NSButton) { 707 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 708 1089 } 709 1090 } 710 1091
+10
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 404 404 onInstrumentVisualChange?() 405 405 } 406 406 407 + func stepMelodicProgram(delta: Int) { 408 + let next = max(0, min(127, Int(melodicProgram) + delta)) 409 + guard next != Int(melodicProgram) else { return } 410 + setMelodicProgram(UInt8(next)) 411 + } 412 + 413 + func stepOctave(delta: Int) { 414 + octaveStepOnce(delta: delta) 415 + } 416 + 407 417 // MARK: - Instrument backend (GM vs GarageBand) 408 418 409 419 enum InstrumentBackend: String { case gm, garageBand = "gb" }
+1496 -67
slab/menuband/Sources/MenuBand/MenuBarWaveformStrip.swift
··· 1 1 import AppKit 2 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 + 3 938 /// Thin floating waveform strip that slides down from beneath the menubar 4 939 /// piano when notes are played, and slides back up after a configurable 5 940 /// idle delay. Hidden whenever the settings popover or floating piano ··· 8 943 /// The panel sits one level below `NSWindow.Level.mainMenu` so the menubar 9 944 /// itself occludes it. The slide animation moves the panel from behind the 10 945 /// menubar downward into view, and reverses to hide. 11 - final class MenuBarWaveformStrip { 946 + private final class LiquidMenuBarWaveformStrip: NSObject, MenuBarWaveformStripImplementation { 12 947 private let menuBand: MenuBandController 13 948 private let waveformView = WaveformView() 14 - private let waveformBezel = NSView() 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() 15 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() 16 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? 17 965 private var panel: NSPanel? 18 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)? 19 972 20 973 /// How long to keep the strip visible after the last note ends. 21 974 private let hideDelay: TimeInterval = 2.0 22 975 private var hideWorkItem: DispatchWorkItem? 976 + private let customOriginXKey = "notepat.waveformStrip.customOriginX" 977 + private let customOriginYKey = "notepat.waveformStrip.customOriginY" 978 + private var customOrigin: NSPoint? 23 979 24 - /// Strip height in points. 25 - private static let stripHeight: CGFloat = 56 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" 26 996 27 997 /// Animation duration in seconds. 28 - private static let slideDuration: TimeInterval = 0.22 998 + private static let fadeInDuration: TimeInterval = 0.18 999 + private static let fadeOutDuration: TimeInterval = 0.18 29 1000 30 1001 /// Window level just below the menubar so the menubar occludes the 31 1002 /// strip while it's tucked behind. 32 - private static let stripLevel = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue - 1) 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 + } 33 1031 34 1032 /// CVDisplayLink-driven slide animation. NSAnimationContext + animator() 35 1033 /// proxy doesn't reliably move borderless non-activating panels, so we ··· 40 1038 private var slideToY: CGFloat = 0 41 1039 private var slideCompletion: (() -> Void)? 42 1040 private var isSliding = false 1041 + private var isPointerInsideStrip = false 1042 + private var isDraggingStrip = false 43 1043 44 1044 /// True while the strip panel is on screen (visible or animating in). 45 1045 var isShown: Bool { panel?.isVisible == true } 1046 + var isDocked: Bool { customOrigin == nil } 46 1047 47 1048 /// External suppression — callers set this when the popover or floating 48 1049 /// palette opens. While suppressed, `showIfNeeded` is a no-op and any ··· 56 1057 57 1058 init(menuBand: MenuBandController) { 58 1059 self.menuBand = menuBand 1060 + super.init() 1061 + customOrigin = loadCustomOrigin() 59 1062 waveformView.menuBand = menuBand 1063 + waveformView.setSurfaceStyle(.glassEmbedded) 60 1064 waveformBezel.wantsLayer = true 61 - waveformBezel.layer?.cornerRadius = 6 62 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 63 - waveformBezel.layer?.borderWidth = 1 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 + } 64 1076 heldNotesStack.orientation = .horizontal 65 1077 heldNotesStack.alignment = .centerY 66 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() 67 1157 } 68 1158 69 1159 /// Pre-build the panel so the first note doesn't pay construction cost. ··· 86 1176 87 1177 /// Call whenever `litNotes` becomes empty (all notes released). 88 1178 func scheduleHide() { 1179 + guard !isPointerInsideStrip, !isDraggingStrip else { 1180 + cancelPendingHide() 1181 + return 1182 + } 89 1183 cancelPendingHide() 90 1184 let work = DispatchWorkItem { [weak self] in 91 1185 self?.hide(animated: true) ··· 98 1192 func dismiss() { 99 1193 cancelPendingHide() 100 1194 stopSlide() 1195 + isPointerInsideStrip = false 1196 + setControlsVisible(false, animated: false) 101 1197 waveformView.isLive = false 102 1198 panel?.orderOut(nil) 103 1199 } ··· 106 1202 func reposition(statusItemButton: NSStatusBarButton?) { 107 1203 statusButton = statusItemButton 108 1204 guard let panel = panel, panel.isVisible, !isSliding else { return } 1205 + guard isDocked else { return } 109 1206 positionPanel(panel) 110 1207 } 111 1208 ··· 115 1212 refreshReadout() 116 1213 } 117 1214 1215 + func registerArrowInput() { 1216 + guard !suppressed else { return } 1217 + showIfNeeded() 1218 + refreshReadout() 1219 + if menuBand.litNotes.isEmpty { 1220 + scheduleHide() 1221 + } 1222 + } 1223 + 118 1224 // MARK: - Geometry 119 1225 120 1226 /// Compute the target frame for the strip: directly below the menubar, ··· 135 1241 let windowRect = button.convert(localRect, to: nil) 136 1242 let screenRect = buttonWindow.convertToScreen(windowRect) 137 1243 138 - return NSRect( 1244 + let stripHeight = currentStripHeight(for: screenRect.width) 1245 + let anchoredFrame = NSRect( 139 1246 x: screenRect.origin.x, 140 - y: screenRect.origin.y - Self.stripHeight, 1247 + y: screenRect.origin.y - stripHeight, 141 1248 width: screenRect.width, 142 - height: Self.stripHeight 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 143 1256 ) 144 1257 } 145 1258 146 - // MARK: - Slide animation 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 147 1318 148 1319 private func show() { 149 1320 if panel == nil { buildPanel() } 150 1321 guard let panel = panel, let target = targetFrame() else { return } 151 1322 152 - // Start tucked behind the menubar (origin shifted up by strip height). 153 - let hiddenY = target.origin.y + target.height 154 - panel.setFrame(NSRect(x: target.origin.x, y: hiddenY, 155 - width: target.width, height: target.height), 156 - display: true) 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 157 1330 applyAppearanceToVisualizer() 158 1331 applyWaveformTint() 159 1332 refreshReadout() 160 1333 waveformView.isLive = true 161 1334 panel.orderFrontRegardless() 162 1335 163 - // Slide down to the target position. 164 - startSlide(fromY: hiddenY, toY: target.origin.y) { [weak self] in 165 - // Ensure we land exactly at the target. 166 - panel.setFrameOrigin(target.origin) 167 - self?.isSliding = false 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 168 1344 } 169 1345 } 170 1346 171 1347 private func hide(animated: Bool) { 172 1348 cancelPendingHide() 173 1349 stopSlide() 1350 + guard !isPointerInsideStrip, !isDraggingStrip else { return } 174 1351 guard let panel = panel, panel.isVisible else { 175 1352 waveformView.isLive = false 176 1353 return 177 1354 } 178 1355 if !animated { 1356 + panel.animator().alphaValue = 1.0 179 1357 waveformView.isLive = false 180 1358 panel.orderOut(nil) 181 1359 return 182 1360 } 183 - // Slide back up behind the menubar. 184 - let currentY = panel.frame.origin.y 185 - let hiddenY = currentY + panel.frame.height 186 - startSlide(fromY: currentY, toY: hiddenY) { [weak self] in 187 - self?.waveformView.isLive = false 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 188 1368 panel.orderOut(nil) 189 - self?.isSliding = false 1369 + panel.alphaValue = 1.0 1370 + self.isSliding = false 190 1371 } 191 1372 } 192 1373 ··· 209 1390 let opaque = Unmanaged.passUnretained(self).toOpaque() 210 1391 CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx -> CVReturn in 211 1392 guard let ctx = ctx else { return kCVReturnSuccess } 212 - let strip = Unmanaged<MenuBarWaveformStrip>.fromOpaque(ctx).takeUnretainedValue() 1393 + let strip = Unmanaged<LiquidMenuBarWaveformStrip>.fromOpaque(ctx).takeUnretainedValue() 213 1394 DispatchQueue.main.async { strip.tickSlide() } 214 1395 return kCVReturnSuccess 215 1396 }, opaque) ··· 223 1404 return 224 1405 } 225 1406 let elapsed = CACurrentMediaTime() - slideStartTime 226 - let t = min(1.0, elapsed / Self.slideDuration) 1407 + let t = min(1.0, elapsed / Self.fadeInDuration) 227 1408 // Ease-out cubic for show, ease-in cubic for hide. 228 1409 let eased: CGFloat 229 1410 if slideToY < slideFromY { ··· 260 1441 // MARK: - Panel construction 261 1442 262 1443 private func buildPanel() { 263 - let p = NSPanel( 264 - contentRect: NSRect(origin: .zero, size: NSSize(width: 200, height: Self.stripHeight)), 265 - styleMask: [.borderless, .nonactivatingPanel], 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], 266 1451 backing: .buffered, 267 1452 defer: false 268 1453 ) 269 - p.isOpaque = true 270 - p.backgroundColor = .black 1454 + p.isOpaque = false 1455 + p.backgroundColor = .clear 271 1456 p.hasShadow = true 272 1457 p.level = Self.stripLevel 273 1458 p.collectionBehavior = [.transient, .ignoresCycle] 274 1459 p.hidesOnDeactivate = false 275 1460 p.canHide = false 276 - p.isMovable = false 1461 + p.isMovable = true 1462 + p.isMovableByWindowBackground = false 277 1463 p.animationBehavior = .none 278 1464 p.acceptsMouseMovedEvents = false 279 - 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] 280 1471 waveformView.translatesAutoresizingMaskIntoConstraints = false 281 1472 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 1473 + waveformClipView.translatesAutoresizingMaskIntoConstraints = false 1474 + instrumentArrows.translatesAutoresizingMaskIntoConstraints = false 1475 + instrumentRowContainer.translatesAutoresizingMaskIntoConstraints = false 282 1476 instrumentLabel.translatesAutoresizingMaskIntoConstraints = false 1477 + closeButton.translatesAutoresizingMaskIntoConstraints = false 1478 + dockButton.translatesAutoresizingMaskIntoConstraints = false 1479 + expandButton.translatesAutoresizingMaskIntoConstraints = false 1480 + heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 283 1481 heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 284 - let content = NSView() 285 - content.wantsLayer = true 286 - content.layer?.backgroundColor = NSColor.clear.cgColor 287 - p.contentView = content 288 - p.contentView!.addSubview(waveformBezel) 289 - p.contentView!.addSubview(instrumentLabel) 290 - p.contentView!.addSubview(heldNotesStack) 291 - waveformBezel.addSubview(waveformView) 292 - let bezelInset: CGFloat = 5 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 293 1583 NSLayoutConstraint.activate([ 294 - waveformBezel.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor), 295 - waveformBezel.trailingAnchor.constraint(equalTo: p.contentView!.trailingAnchor), 296 - waveformBezel.topAnchor.constraint(equalTo: p.contentView!.topAnchor), 297 - waveformBezel.heightAnchor.constraint(equalToConstant: 36), 298 - waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 299 - waveformView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 300 - waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 301 - waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 302 - instrumentLabel.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor, constant: 6), 303 - instrumentLabel.bottomAnchor.constraint(equalTo: p.contentView!.bottomAnchor, constant: -4), 304 - heldNotesStack.centerXAnchor.constraint(equalTo: p.contentView!.centerXAnchor), 305 - heldNotesStack.centerYAnchor.constraint(equalTo: instrumentLabel.centerYAnchor), 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), 306 1613 ]) 307 1614 applyAppearanceToVisualizer() 308 1615 applyWaveformTint() 309 1616 refreshReadout() 1617 + setControlsVisible(false, animated: false) 310 1618 311 1619 panel = p 312 1620 } 313 1621 314 1622 private func positionPanel(_ panel: NSPanel) { 315 1623 guard let target = targetFrame() else { return } 1624 + waveformBezelHeightConstraint?.constant = currentWaveformBezelHeight(for: target.width) 316 1625 panel.setFrame(target, display: true) 317 1626 } 318 1627 ··· 320 1629 let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 321 1630 waveformView.setLightMode(!isDark) 322 1631 if isDark { 323 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 324 - instrumentLabel.textColor = .white 1632 + waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.04).cgColor 1633 + instrumentLabel.textColor = .labelColor 325 1634 } else { 326 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.82, alpha: 1.0).cgColor 327 - instrumentLabel.textColor = .black 1635 + waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor 1636 + instrumentLabel.textColor = .labelColor 328 1637 } 329 1638 } 330 1639 331 1640 private func applyWaveformTint() { 1641 + let stripTint: NSColor 332 1642 if menuBand.midiMode { 333 1643 waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 334 1644 waveformView.setBaseColor(.controlAccentColor) 335 1645 waveformBezel.layer?.borderColor = NSColor.controlAccentColor 336 - .withAlphaComponent(0.55).cgColor 1646 + .withAlphaComponent(0.24).cgColor 1647 + stripTint = NSColor.controlAccentColor.withAlphaComponent(0.20) 337 1648 } else { 338 1649 waveformView.setDotMatrix(nil) 339 1650 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 340 1651 let familyColor = InstrumentListView.colorForProgram(safe) 341 1652 waveformView.setBaseColor(familyColor) 342 1653 waveformBezel.layer?.borderColor = familyColor 343 - .withAlphaComponent(0.55).cgColor 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 + } 344 1679 } 345 1680 } 346 1681 ··· 349 1684 let familyColor = menuBand.midiMode 350 1685 ? NSColor.controlAccentColor 351 1686 : InstrumentListView.colorForProgram(safe) 352 - instrumentLabel.stringValue = GeneralMIDI.programNames[safe] 353 - instrumentLabel.font = NSFont.systemFont(ofSize: 10, weight: .black) 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 + ) 354 1709 for view in heldNotesStack.arrangedSubviews { 355 1710 heldNotesStack.removeArrangedSubview(view) 356 1711 view.removeFromSuperview() ··· 380 1735 ]) 381 1736 return box 382 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 + 383 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 + }
+24 -2
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 13 13 /// driven by MTKView's internal CVDisplayLink at the screen's native 14 14 /// refresh. Hidden when MIDI mode is on. 15 15 final class WaveformView: MTKView { 16 + enum SurfaceStyle { 17 + case standard 18 + case glassEmbedded 19 + } 20 + 16 21 weak var menuBand: MenuBandController? 17 22 18 23 private static let barCount = 16 ··· 44 49 /// we need a per-view lease bit to avoid over-releasing the controller's 45 50 /// shared capture refcount. 46 51 private var hasCaptureLease = false 52 + private var surfaceStyle: SurfaceStyle = .standard 47 53 48 54 var isLive: Bool = false { 49 55 didSet { ··· 212 218 } 213 219 214 220 override var isOpaque: Bool { true } 221 + override var mouseDownCanMoveWindow: Bool { true } 222 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 215 223 216 224 private func buildPipeline(device: MTLDevice) { 217 225 do { ··· 294 302 /// instead of brightening to white, so peak still reads as 295 303 /// "hotter" without washing out against the light substrate. 296 304 func setLightMode(_ isLight: Bool) { 297 - if isLight { 305 + switch (surfaceStyle, isLight) { 306 + case (.standard, true): 298 307 // Warm off-white — closer to a printed page than pure 299 308 // white, so the colored bars don't vibrate against it. 300 309 clearColor = MTLClearColor(red: 0.93, green: 0.92, blue: 0.90, alpha: 1.0) 301 310 uniforms.isLight = 1 302 - } else { 311 + case (.standard, false): 303 312 clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1.0) 304 313 uniforms.isLight = 0 314 + case (.glassEmbedded, true): 315 + clearColor = MTLClearColor(red: 0.86, green: 0.88, blue: 0.90, alpha: 1.0) 316 + uniforms.isLight = 1 317 + case (.glassEmbedded, false): 318 + clearColor = MTLClearColor(red: 0.06, green: 0.08, blue: 0.10, alpha: 1.0) 319 + uniforms.isLight = 0 305 320 } 306 321 display() 322 + } 323 + 324 + func setSurfaceStyle(_ style: SurfaceStyle) { 325 + guard surfaceStyle != style else { return } 326 + surfaceStyle = style 327 + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 328 + setLightMode(!isDark) 307 329 } 308 330 309 331 // MARK: - Per-frame audio analysis