Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: rc1 wip — liquid panel as expanded instrument, popover as music theory

Major reshape of menubar piano:

- Floating panel (liquid glass) hosts the chooser, qwerty map, arrows
cluster, Notepat/Ableton mode picker + ? help, and About / aesthetic
.computer link. Reads as an expanded view of the physical instrument.
- Popover trims down to title row (octave + metronome + MIDI), held-
notes pills + chord-candidate cards (music theory), and meta rows.
Keymap picker, focus + play-palette shortcut bindings retired from
the popover.
- New MetronomeWidget — custom-drawn analog body with a real swinging
needle + click-clock Tink at each beat, BPM via mini NSSlider.
Spacebar toggles play while the popover is up.
- Popover ↔ floating panel pairing: opening the popover forces the
collapsed liquid panel to dock snug-left of it. Closing the popover
hard-closes the panel (no held-notes guard, no auto-hide schedule).
- InstrumentListView gets bee-vision typography (font size + weight
scale with grid distance from selected); selected cell breathes /
pulses with audio. Visualizer reverted to per-column LED bars.
- Drum routing fix: tap path was misclassifying low-octaveShift notes
as drums based on played pitch instead of visual cell, diverging
from the qwerty path. Now both paths use the visible cell.
- Cmd-Ctrl-Opt-K repurposed: opens the expanded floating panel
centered on the active display. Type-mode (P) + floating-piano
(Space) shortcuts removed.
- WaveformView (Metal) + WaveformShaders.metal + LedMeterView
retired. Glass effect held active via PianoWaveformPanel
isMainWindow override so popover-stealing focus doesn't shift
perceived blur. Tint disabled on the collapsed glass — even
normalized hue tints perceptibly shifted blur between voices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1251 -1324
-8
slab/menuband/Package.swift
··· 10 10 path: "Sources/MenuBand", 11 11 resources: [ 12 12 .process("Resources"), 13 - // SwiftPM doesn't auto-compile .metal files in 14 - // executable targets — declaring it as a processed 15 - // resource makes SwiftPM emit a default.metallib into 16 - // the module bundle, which `device.makeDefaultLibrary( 17 - // bundle: .module)` then finds at runtime. Without 18 - // this the visualizer renders solid black: the Metal 19 - // pipeline fails with "no default library was found". 20 - .process("WaveformShaders.metal"), 21 13 ] 22 14 ), 23 15 ]
+111 -36
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 185 185 self?.popoverVC?.syncFromController() 186 186 self?.updatePianoWaveformWindowSuppression() 187 187 } 188 + // While the popover is on screen, the floating panel's 189 + // collapsed frame snaps right-aligned to the popover's 190 + // left edge. The closure returns nil when the popover 191 + // isn't visible, falling the panel back to the menubar 192 + // status-item anchor. 193 + pianoWaveformWindowDelegate.popoverFrameProvider = { [weak self] in 194 + guard let self = self, self.popover.isShown else { return nil } 195 + return self.popover.contentViewController?.view.window?.frame 196 + } 188 197 pianoWaveformWindowDelegate.isPianoFocusActive = { [weak self] in 189 198 self?.localCapture.isArmed ?? false 190 199 } ··· 239 248 } 240 249 pianoWaveformWindowDelegate.warmUp() 241 250 242 - registerTypeModeHotkey() 251 + // Type-mode (cmd-ctrl-opt-P) and floating-piano (cmd-ctrl-opt- 252 + // space) shortcuts retired — they were undocumented power-user 253 + // affordances and overlap with the menubar piano + popover 254 + // flow. Layout toggle stays; focus shortcut (K) is repurposed 255 + // below to open the expanded floating panel centered. 243 256 _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) 244 - _ = registerPianoWaveformHotkey(MenuBandShortcutPreferences.playPaletteShortcut) 245 257 registerLayoutToggleHotkey() 246 258 247 259 // Dev affordance: post the ··· 275 287 self.toggleKeyboardLayoutShortcut() 276 288 return true 277 289 } 278 - if self.popover.isShown == false && self.pianoWaveformWindowDelegate.isShown == false { 290 + // Spacebar toggles the metronome whenever the popover is 291 + // open. Consumed in both directions so the keystroke 292 + // never falls through to the focused app or to the note 293 + // path (where space would otherwise behave like an 294 + // unmapped key consume). 295 + if keyCode == 49 /* kVK_Space */, self.popover.isShown { 296 + if isDown && !isRepeat { 297 + self.popoverVC?.toggleMetronome() 298 + } 299 + return true 300 + } 301 + // Arrow keys always step the GM program — the chooser 302 + // grid lives in the floating panel that pairs with the 303 + // popover, so when either is open the user expects ←/→/ 304 + // ↑/↓ to drive the bee-vision center. Each press both 305 + // commits the new program AND auditions a preview note 306 + // so the user hears the new voice without manually 307 + // pressing a piano key. 308 + let arrowDelta: Int? = { 279 309 switch keyCode { 280 - case 123: // kVK_LeftArrow 281 - if isDown { 282 - self.pianoWaveformWindowDelegate.registerArrowInput() 283 - if !isRepeat { self.menuBand.stepMelodicProgram(delta: -1) } 310 + case 123: return -1 311 + case 124: return +1 312 + case 125: return +InstrumentListView.cols 313 + case 126: return -InstrumentListView.cols 314 + default: return nil 315 + } 316 + }() 317 + if let delta = arrowDelta { 318 + if isDown { 319 + self.pianoWaveformWindowDelegate.registerArrowInput() 320 + if !isRepeat { 321 + self.menuBand.stepMelodicProgram(delta: delta) 322 + self.menuBand.auditionCurrentProgram() 284 323 } 285 - return true 286 - case 124: // kVK_RightArrow 287 - if isDown { 288 - self.pianoWaveformWindowDelegate.registerArrowInput() 289 - if !isRepeat { self.menuBand.stepMelodicProgram(delta: +1) } 290 - } 291 - return true 292 - case 125: // kVK_DownArrow 293 - if isDown { 294 - self.pianoWaveformWindowDelegate.registerArrowInput() 295 - if !isRepeat { self.menuBand.stepMelodicProgram(delta: +InstrumentListView.cols) } 296 - } 297 - return true 298 - case 126: // kVK_UpArrow 299 - if isDown { 300 - self.pianoWaveformWindowDelegate.registerArrowInput() 301 - if !isRepeat { self.menuBand.stepMelodicProgram(delta: -InstrumentListView.cols) } 302 - } 303 - return true 304 - default: 305 - break 306 324 } 325 + return true 307 326 } 308 327 let consumed = self.menuBand.handleLocalKey( 309 328 keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags ··· 561 580 } 562 581 563 582 private func toggleFocusCaptureFromShortcut() { 583 + // Cmd-Ctrl-Opt-K now opens (or toggles) the expanded 584 + // floating piano centered on the active display. With the 585 + // popover closed, `popoverFrameProvider` returns nil and 586 + // `expandedFrame` falls back to a `centeredOrigin`, so the 587 + // panel pops up dead-center. 588 + closePopover() 589 + if pianoWaveformWindowDelegate.isShown { 590 + pianoWaveformWindowDelegate.dismiss(reason: .programmatic) 591 + } else { 592 + pianoWaveformWindowDelegate.showExpandedForPopover() 593 + } 594 + } 595 + 596 + /// Legacy focus-capture path — preserved as a stub so the 597 + /// existing `focusCaptureArmedByShortcut` callers keep 598 + /// compiling. The shortcut itself was repurposed above. 599 + private func _unusedLegacyFocusCapture() { 564 600 if pianoWaveformWindowDelegate.isKeyboardFocused { 565 601 finishPianoWaveformKeyboardFocus() 566 602 return ··· 717 753 guard let self = self else { return } 718 754 // Caps lock latches the mode; shift is the momentary. Either 719 755 // arms linger and shows uppercase labels. 720 - let armed = event.modifierFlags.contains(.shift) 721 - || event.modifierFlags.contains(.capsLock) 756 + let caps = event.modifierFlags.contains(.capsLock) 757 + let armed = event.modifierFlags.contains(.shift) || caps 758 + var dirty = false 722 759 if KeyboardIconRenderer.labelsUppercase != armed { 723 760 KeyboardIconRenderer.labelsUppercase = armed 724 - self.updateIcon() 761 + dirty = true 762 + } 763 + if KeyboardIconRenderer.lingerCapsLatched != caps { 764 + KeyboardIconRenderer.lingerCapsLatched = caps 765 + dirty = true 725 766 } 767 + if dirty { self.updateIcon() } 726 768 } 727 769 globalShiftMonitor = NSEvent.addGlobalMonitorForEvents( 728 770 matching: .flagsChanged ··· 735 777 // until something toggles. Read the current modifier mask once 736 778 // at startup to seed the renderer correctly. 737 779 let initial = NSEvent.modifierFlags 738 - let initialArmed = initial.contains(.shift) || initial.contains(.capsLock) 780 + let initialCaps = initial.contains(.capsLock) 781 + let initialArmed = initial.contains(.shift) || initialCaps 782 + var initialDirty = false 739 783 if KeyboardIconRenderer.labelsUppercase != initialArmed { 740 784 KeyboardIconRenderer.labelsUppercase = initialArmed 741 - updateIcon() 785 + initialDirty = true 786 + } 787 + if KeyboardIconRenderer.lingerCapsLatched != initialCaps { 788 + KeyboardIconRenderer.lingerCapsLatched = initialCaps 789 + initialDirty = true 742 790 } 791 + if initialDirty { updateIcon() } 743 792 } 744 793 745 794 private func applyForcedLayoutIfAny() { ··· 976 1025 // It's a temporal "you're capturing right now" hint, not a 977 1026 // permanent overlay. 978 1027 KeyboardIconRenderer.activeKeymap = menuBand.keymap 1028 + // Sync the playing flag so the chip's linger fermata can hide 1029 + // when the user is just resting on shift between phrases. 1030 + KeyboardIconRenderer.playingActive = !menuBand.litNotes.isEmpty 979 1031 // Note: miniVisualizerLevel + miniVisualizerPhase are driven by 980 1032 // the visualizerAnimTimer (24fps smoothed VU motion), not from 981 1033 // here — overriding them on every note-event redraw would ··· 1215 1267 displayNote: startDisplayNote, 1216 1268 linger: initialShift 1217 1269 ) 1218 - pianoWaveformWindowDelegate.showIfNeeded() 1270 + // Menubar piano taps no longer auto-open the floating panel — 1271 + // it's reserved for the popover-paired flow (and the explicit 1272 + // mini-visualizer chip / shortcut). Earlier this called 1273 + // showIfNeeded here, which surprised users who just wanted to 1274 + // play a quick note in the menubar. 1219 1275 // Arm sandbox-friendly local capture on a real piano click. We 1220 1276 // skip arming when global TYPE mode is already on — the global 1221 1277 // tap is already handling keys, doubling up would re-trigger ··· 1257 1313 displayNote: nxtDisplay, 1258 1314 linger: shiftNow 1259 1315 ) 1260 - pianoWaveformWindowDelegate.showIfNeeded() 1261 1316 currentPlayed = nxtPlayed 1262 1317 } else { 1263 1318 currentPlayed = nil ··· 1288 1343 if let m = clickAwayMonitor { NSEvent.removeMonitor(m); clickAwayMonitor = nil } 1289 1344 if let m = popoverEscMonitor { NSEvent.removeMonitor(m); popoverEscMonitor = nil } 1290 1345 appBeforePopover = nil 1346 + // Floating window is paired with the popover — close together. 1347 + pianoWaveformWindowDelegate.dismiss(reason: .programmatic) 1291 1348 updatePianoWaveformWindowSuppression() 1292 1349 } 1293 1350 ··· 1333 1390 : frontmost 1334 1391 NSApp.activate(ignoringOtherApps: true) 1335 1392 popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 1393 + // Pair the popover with the COLLAPSED floating panel — 1394 + // that's where the GM chooser lives. Expanded is only 1395 + // reachable by user action (the expand button on the 1396 + // collapsed panel). 1397 + pianoWaveformWindowDelegate.showCollapsedForPopover() 1336 1398 updatePianoWaveformWindowSuppression() 1399 + // Arm local key capture so arrow keys + spacebar reach 1400 + // our handler while the popover is up. The InstrumentList 1401 + // used to be in the popover and grabbed keys via its 1402 + // first-responder; once it moved to the floating panel 1403 + // nothing was capturing arrows for the controller. 1404 + if !localCapture.isArmed { localCapture.arm() } 1337 1405 DispatchQueue.main.async { 1338 1406 self.popover.contentViewController?.view.window?.makeKey() 1339 1407 } ··· 1371 1439 guard pianoWaveformWindowDelegate.isCollapsedState else { return } 1372 1440 if !menuBand.litNotes.isEmpty { 1373 1441 pianoWaveformWindowDelegate.showIfNeeded() 1374 - } else { 1442 + } else if !popover.isShown { 1443 + // Auto-hide the collapsed strip only when it's standalone. 1444 + // While the popover is up the panel is paired with it and 1445 + // must stay visible — scheduleHide would otherwise fire 1446 + // ~2s after any silent state change (e.g., arrow-key 1447 + // stepping) and dismiss the panel mid-popover. 1375 1448 pianoWaveformWindowDelegate.scheduleHide() 1449 + } else { 1450 + pianoWaveformWindowDelegate.cancelPendingHide() 1376 1451 } 1377 1452 } 1378 1453
+105 -71
slab/menuband/Sources/MenuBand/InstrumentMapView.swift
··· 48 48 private var trackingArea: NSTrackingArea? 49 49 50 50 // MARK: - Visualizer state 51 - /// One smoothed display level per column (0…1), updated every display- 52 - /// link tick via the same RMS+auto-gain+asymmetric-smoothing pipeline 53 - /// the Metal `WaveformView` uses, so the grid meter and the dedicated 54 - /// visualizer read identically. Renderer maps each value to a lit 55 - /// half-height around the grid midline. 51 + /// One smoothed display level per column (0…1), driving the 52 + /// per-column LED bars that bloom outward from the grid midline. 56 53 private var columnPeaks = [Float](repeating: 0, count: cols) 57 - /// Per-tick raw RMS per column, before gain + smoothing. Kept as a 58 - /// member so it doesn't reallocate every frame. 54 + /// Per-tick raw RMS per column, before gain + smoothing. 59 55 private var columnLevels = [Float](repeating: 0, count: cols) 60 - /// Reusable buffer the synth fills during snapshotWaveform — allocated 61 - /// once at init so the per-frame tick doesn't churn the heap. 56 + /// Reusable buffer the synth fills during snapshotWaveform. 62 57 private var sampleScratch = [Float](repeating: 0, count: 1024) 63 - /// Auto-gain envelope. Snaps up on attack, decays slowly on release so 64 - /// a sustained quiet voice still climbs into useful range. Floor of 65 - /// 0.05 prevents infinite gain on near-silence. 58 + /// Auto-gain envelope. 66 59 private var smoothedPeak: Float = 0.05 60 + /// Slow blink phase for the selected cell — independent of audio 61 + /// so the chosen instrument still breathes during silence. 62 + private var blinkPhase: Double = 0 67 63 private var visualizerLink: CVDisplayLink? 68 64 private var hasCaptureLease = false 69 - /// Coalesce display callbacks in case main thread runs slow — same 70 - /// pattern WaveformView uses to keep its display link from queueing 71 - /// stale draw requests. 72 65 private var pendingTickLock = NSLock() 73 66 private var pendingTick = false 74 67 75 68 override var isFlipped: Bool { true } // top-down rows, reading order 69 + /// Cells are clickable + drag-target — let `panel.isMovableByWindowBackground` 70 + /// kick in only on truly empty surfaces, not on the chooser. 71 + override var mouseDownCanMoveWindow: Bool { false } 76 72 77 73 override init(frame frameRect: NSRect) { 78 74 super.init(frame: frameRect) ··· 127 123 pendingTickLock.lock() 128 124 pendingTick = false 129 125 pendingTickLock.unlock() 130 - // Drop the lit cells back to baseline so the popover doesn't flash a 131 - // frozen meter the next time it opens. 132 126 for c in 0..<columnPeaks.count { columnPeaks[c] = 0 } 127 + blinkPhase = 0 133 128 } 134 129 135 130 private func tickVisualizer() { ··· 141 136 mb.synthSnapshotWaveform(into: &sampleScratch) 142 137 let perCol = sampleScratch.count / Self.cols 143 138 guard perCol > 0 else { return } 144 - // RMS per column instead of peak — peak detection makes the meter 145 - // hop around as zero-crossings drift across chunk boundaries; RMS 146 - // sits at the column's true amplitude. Same call WaveformView 147 - // makes, so the two surfaces stay synchronized in feel. 148 139 var framePeak: Float = 0 149 140 for c in 0..<Self.cols { 150 141 let base = c * perCol ··· 157 148 columnLevels[c] = rms 158 149 if rms > framePeak { framePeak = rms } 159 150 } 160 - // Auto-gain — snap up on attack, decay slowly so a sustained quiet 161 - // note still pushes the meter into visible range. 162 151 if framePeak > smoothedPeak { 163 152 smoothedPeak = framePeak 164 153 } else { 165 154 smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 166 155 } 167 156 let gain = 0.95 / smoothedPeak 168 - // Asymmetric per-column smoothing — fast attack to keep transient 169 - // bite, slow decay to suppress phase-induced wobble. Values match 170 - // WaveformView so the two visualizers behave identically. 171 157 let attack: Float = 0.55 172 158 let decay: Float = 0.18 173 159 var changed = false ··· 181 167 changed = true 182 168 } 183 169 } 184 - if changed { needsDisplay = true } 170 + // Steady ~1.6Hz breathe phase for the selected cell, independent 171 + // of audio so the chosen voice keeps gently pulsing during silence. 172 + blinkPhase += 1.0 / 60.0 * 1.6 173 + if blinkPhase > 1000 { blinkPhase -= 1000 } 174 + if changed || Int(blinkPhase * 15) % 4 == 0 { 175 + needsDisplay = true 176 + } 185 177 } 186 178 187 179 override var intrinsicContentSize: NSSize { ··· 277 269 278 270 // MARK: - Drawing 279 271 280 - private static let numAttrs: [NSAttributedString.Key: Any] = [ 281 - .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .medium), 282 - .foregroundColor: NSColor.labelColor.withAlphaComponent(0.85), 283 - ] 284 - private static let numAttrsSelected: [NSAttributedString.Key: Any] = [ 285 - .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .bold), 286 - .foregroundColor: NSColor.white, 287 - ] 272 + /// Bee-vision typography: program numbers grow toward the 273 + /// selected cell and shrink toward the edges of the grid. The 274 + /// selected cell renders biggest with a heavy weight; cells one 275 + /// step away render slightly smaller; faraway cells fall to the 276 + /// minimum size so the eye is drawn to the active voice. 277 + private static func numberFont(forDistance d: CGFloat, isSelected: Bool) -> NSFont { 278 + if isSelected { 279 + return NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .black) 280 + } 281 + // Continuous falloff so neighbors of any direction get 282 + // proportional emphasis. Tuned so dist 1 ≈ 11pt, dist 4 ≈ 9pt 283 + // and the floor (dist 8+) stays at the original 8.5pt. 284 + let size = max(8.5, 12.0 - d * 0.85) 285 + let weight: NSFont.Weight = d <= 1.5 ? .bold : (d <= 3.0 ? .semibold : .medium) 286 + return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight) 287 + } 288 288 289 289 override func draw(_ dirtyRect: NSRect) { 290 290 super.draw(dirtyRect) 291 + let selectedRow = Int(selectedProgram) / Self.cols 292 + let selectedCol = Int(selectedProgram) % Self.cols 293 + // Selected cell breathes in alpha at ~1.6 Hz regardless of 294 + // audio; on top of that, the loudest column's level boosts the 295 + // gain so the chosen voice pulses with playing too. 296 + let blinkAmount = 0.5 + 0.5 * CGFloat(sin(blinkPhase * 2 * .pi)) 297 + let amp = CGFloat(columnPeaks.max() ?? 0) 298 + 291 299 for p in 0..<128 { 292 300 let r = cellRect(program: p) 293 301 guard r.intersects(dirtyRect) else { continue } 294 302 let fam = familyColor(forProgram: p) 295 - 296 - // Background — family-tinted at low opacity so the grid reads 297 - // as 16 rainbow rows. 298 - fam.withAlphaComponent(0.20).setFill() 299 - NSBezierPath(rect: r).fill() 303 + let row = p / Self.cols 304 + let col = p % Self.cols 305 + let dx = CGFloat(col - selectedCol) 306 + let dy = CGFloat(row - selectedRow) 307 + let dist = sqrt(dx * dx + dy * dy) 308 + let isSelected = (selectedProgram == UInt8(p)) 300 309 301 - if selectedProgram == UInt8(p) { 302 - fam.setFill() 310 + if isSelected { 311 + // Selected cell: family color pulsing toward white, 312 + // tracking both the steady blink and audio amplitude. 313 + let pulse = min(1.0, 0.55 + blinkAmount * 0.30 + amp * 0.5) 314 + let bg = fam.blended(withFraction: amp * 0.4, of: .white) ?? fam 315 + bg.withAlphaComponent(pulse).setFill() 303 316 NSBezierPath(rect: r).fill() 304 - } else if hoveredProgram == UInt8(p) { 305 - NSColor.controlAccentColor.withAlphaComponent(0.35).setFill() 317 + } else { 318 + // Family-tinted bed at low opacity so the grid still 319 + // reads as 16 rainbow rows. 320 + fam.withAlphaComponent(0.20).setFill() 306 321 NSBezierPath(rect: r).fill() 322 + if hoveredProgram == UInt8(p) { 323 + NSColor.controlAccentColor.withAlphaComponent(0.35).setFill() 324 + NSBezierPath(rect: r).fill() 325 + } 307 326 } 308 327 309 - // Chiclet-style keycap outline. The cell's hit area is the 310 - // full rect (so dragging across stays seamless — no 311 - // dead-space gaps between voices), but we draw the visible 312 - // outline inset further with a soft corner radius so each 313 - // cell reads as its own little keyboard button with 314 - // breathing space around it. The outline picks up the 315 - // cell's family hue so the button itself signals which 316 - // GM family it belongs to even before you read the 317 - // number. 328 + // Chiclet-style keycap outline. 318 329 let capPath = NSBezierPath(roundedRect: r.insetBy(dx: 1.75, dy: 1.5), 319 330 xRadius: 2.5, yRadius: 2.5) 320 - fam.withAlphaComponent(0.65).setStroke() 321 - capPath.lineWidth = 0.8 331 + // Selected cell gets a brighter ring so it reads as the 332 + // visual focus even without the radial pulse on top. 333 + let strokeAlpha: CGFloat = isSelected ? 1.0 : 0.65 334 + fam.withAlphaComponent(strokeAlpha).setStroke() 335 + capPath.lineWidth = isSelected ? 1.4 : 0.8 322 336 capPath.stroke() 323 337 324 - // Program number, centered. Leading zeros dropped — bare 325 - // "0..127" reads cleaner as a keycap label. 326 - let attrs = (selectedProgram == UInt8(p)) ? Self.numAttrsSelected : Self.numAttrs 338 + // Bee-vision program number — biggest at the selected 339 + // cell, tapering with distance. Shadow on the selected 340 + // cell so the giant glyph lifts cleanly off the bg. 341 + let font = Self.numberFont(forDistance: dist, isSelected: isSelected) 342 + let textColor: NSColor 343 + let textAlpha: CGFloat 344 + if isSelected { 345 + textColor = .white 346 + textAlpha = 1.0 347 + } else { 348 + textColor = NSColor.labelColor 349 + // Distant cells fade toward gridroom-tone so the 350 + // selected one stays visually loudest. 351 + textAlpha = max(0.45, 0.95 - dist * 0.10) 352 + } 353 + var attrs: [NSAttributedString.Key: Any] = [ 354 + .font: font, 355 + .foregroundColor: textColor.withAlphaComponent(textAlpha), 356 + ] 357 + if isSelected { 358 + let shadow = NSShadow() 359 + shadow.shadowColor = NSColor.black.withAlphaComponent(0.6) 360 + shadow.shadowOffset = NSSize(width: 0, height: -1) 361 + shadow.shadowBlurRadius = 2 362 + attrs[.shadow] = shadow 363 + } 327 364 let str = NSAttributedString(string: String(p), attributes: attrs) 328 365 let size = str.size() 329 366 str.draw(at: NSPoint(x: r.midX - size.width / 2, 330 367 y: r.midY - size.height / 2)) 331 368 } 332 - drawVisualizerOverlay(in: dirtyRect) 369 + drawColumnBars(in: dirtyRect) 333 370 } 334 371 335 - /// Per-column center-out meter overlay. Each column's normalized peak 336 - /// (0…1) maps to a half-height; the meter fills rows symmetrically 337 - /// above and below the grid's midline. A silent column shows nothing, 338 - /// a peak of 1 lights the full height. Lit cells glow in a saturated 339 - /// version of the row's own family color (sat → 1.0, brightness → 1.0) 340 - /// so the meter reads as the family palette firing up rather than a 341 - /// neutral white wash. Brightest at the center (newest energy), softer 342 - /// toward the edges. 343 - private func drawVisualizerOverlay(in dirtyRect: NSRect) { 344 - let halfRows = Self.rows / 2 // 8 rows above + 8 below center 372 + /// Per-column center-out LED bars — each column's smoothed peak 373 + /// maps to a half-height of lit cells, blooming above and below 374 + /// the grid's midline. Lit cells glow in a saturated derivative 375 + /// of their family color so the meter reads as the family palette 376 + /// firing up. The selected cell is skipped (the main draw loop 377 + /// already paints it with a brighter pulse). 378 + private func drawColumnBars(in dirtyRect: NSRect) { 379 + let halfRows = Self.rows / 2 345 380 for c in 0..<Self.cols { 346 381 let peak = columnPeaks[c] 347 382 let litHalf = Int((CGFloat(peak) * CGFloat(halfRows)).rounded(.up)) 348 383 guard litHalf > 0 else { continue } 349 384 for offset in 0..<litHalf { 350 - // isFlipped = true → row 0 is visual top. Midline between 351 - // rows 7 and 8; light (7-offset) above and (8+offset) below. 352 385 let intensity = 0.65 - 0.30 * (CGFloat(offset) / CGFloat(halfRows)) 353 386 for row in [halfRows - 1 - offset, halfRows + offset] { 354 387 guard row >= 0, row < Self.rows else { continue } 355 388 let p = row * Self.cols + c 356 389 guard p >= 0, p < 128 else { continue } 390 + if UInt8(p) == selectedProgram { continue } 357 391 let r = cellRect(program: p) 358 392 guard r.intersects(dirtyRect) else { continue } 359 393 saturatedGlow(for: p, alpha: intensity).setFill()
+52 -19
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 66 66 /// and re-issues updateIcon() so the menubar redraws. 67 67 static var labelsUppercase: Bool = false 68 68 69 + /// Caps-lock latched (as opposed to a momentary shift). Drawn 70 + /// state in the chip is gated differently for the two: caps 71 + /// always paints the linger fermata mark (the user has 72 + /// committed to the mode), shift only paints it while at least 73 + /// one note is held (so resting on shift doesn't add chrome). 74 + static var lingerCapsLatched: Bool = false 75 + 76 + /// True while at least one menubar piano note is currently held. 77 + /// Used to gate the shift-momentary linger fermata so it appears 78 + /// only during active play, not while the user is just resting 79 + /// on shift between phrases. 80 + static var playingActive: Bool = false 81 + 69 82 // Render area shrinks with the layout. Compact has no piano keys at 70 83 // all — `lastMidi < firstMidi` makes whiteList() empty. 71 84 static let firstMidi: Int = 60 // C4 (middle C) ··· 913 926 hovered: visualizerHovered, 914 927 color: color, baseAlpha: alpha) 915 928 } 916 - // Linger / bell-ring flourish — small accent tilde tucked at 917 - // the top-right of the music-note glyph whenever shift is held 918 - // or caps lock is latched. Visual cue that any key press will 919 - // ring out instead of cutting at release. Uses the system 920 - // accent color so it pops against either label-color (off) or 921 - // accent-color (MIDI-on) base glyph. 922 - if labelsUppercase { 923 - let flourishAttrs: [NSAttributedString.Key: Any] = [ 924 - .font: NSFont.systemFont(ofSize: 9.5, weight: .heavy), 925 - .foregroundColor: NSColor.controlAccentColor, 926 - ] 927 - let flourish = NSAttributedString(string: "~", attributes: flourishAttrs) 928 - let fSize = flourish.size() 929 - // Anchor the tilde just past the music-note's flag — top 930 - // right of iconBox, nudged so it overlaps the empty space 931 - // above the glyph rather than sitting on top of strokes. 932 - flourish.draw(at: NSPoint( 933 - x: iconBox.maxX - fSize.width + 1.5, 934 - y: iconBox.maxY - fSize.height + 1.0 929 + // Linger / bell-ring flourish — a fermata mark (the music 930 + // notation for "let ring / hold this note") drawn above the 931 + // music-note glyph. Caps lock latches the mode and always 932 + // paints the mark; momentary shift only paints while at 933 + // least one note is actively held, so resting on shift 934 + // between phrases doesn't add visual chrome. 935 + let lingerVisible = lingerCapsLatched 936 + || (labelsUppercase && playingActive) 937 + if lingerVisible, let ctx = NSGraphicsContext.current { 938 + ctx.saveGraphicsState() 939 + NSColor.white.set() 940 + // Anchor the fermata so it overlaps the upper-right of the 941 + // music-note glyph — pushed past the right edge and dropped 942 + // down a few points, so the arc reads as a fat round canopy 943 + // sitting on top of the note rather than floating above it. 944 + // Chunkier arc + bigger dot for a softer, cuter feel. 945 + let fW: CGFloat = 7.5 946 + let fH: CGFloat = 3.5 947 + let fX = iconBox.maxX - fW + 1.5 948 + let fY = iconBox.maxY - fH - 3.0 949 + let arc = NSBezierPath() 950 + arc.appendArc( 951 + withCenter: NSPoint(x: fX + fW / 2, y: fY), 952 + radius: fW / 2, 953 + startAngle: 0, 954 + endAngle: 180 955 + ) 956 + arc.lineWidth = 1.4 957 + arc.lineCapStyle = .round 958 + arc.stroke() 959 + // Bigger dot under the arc — reads as a cartoony round 960 + // bead instead of a single stipple. 961 + let dot = NSBezierPath(ovalIn: NSRect( 962 + x: fX + fW / 2 - 0.95, 963 + y: fY - 0.4, 964 + width: 1.9, 965 + height: 1.9 935 966 )) 967 + dot.fill() 968 + ctx.restoreGraphicsState() 936 969 } 937 970 // Voice-number subscript: tiny digits in the bottom-right 938 971 // corner. The first digit sits where the single-digit case
+25 -5
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 770 770 let visualNote = displayNote ?? midiNote 771 771 tapDisplayNote[midiNote] = visualNote 772 772 if linger { tapLinger[midiNote] = velocity } 773 - let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 773 + // Drum routing is decided by the *visual* key (the cell the user 774 + // tapped), not the post-octave played pitch. Without this, a 775 + // melodic tap at octaveShift < 0 plays below MIDI 60 and the old 776 + // played-pitch test mis-routed it to channel 9 (drum kit) — so 777 + // the same key on the QWERTY (which has no isDrum check) and the 778 + // on-screen piano played different patches. The menubar piano's 779 + // visible range is melodic-only (60–83), so visual-based routing 780 + // keeps both input methods consistent at any octave. 781 + let isDrum = visualNote < UInt8(KeyboardIconRenderer.firstMidi) 774 782 // Synth: rotate across 8 channels so rapid same-note taps overlap 775 783 // (different channels = different voices, no stealing). MIDI: always 776 784 // land on channel 1 (drums on 10) so an Ableton track listening on ··· 779 787 let midiCh: UInt8 = isDrum ? 9 : 0 780 788 tapNoteChannel[midiNote] = synthCh 781 789 midi.sendCC(10, value: pan, channel: midiCh) 790 + // Mirror the keyboard path: also send pan to the local synth's 791 + // per-channel state so synth state stays symmetric across input 792 + // methods. Without this, channels written by keyboard carry over 793 + // their pan into subsequent taps on the same channel. 794 + if !midiMode && !isDrum { synth.setPan(pan, channel: synthCh) } 782 795 if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 783 796 midi.noteOn(midiNote, velocity: velocity, channel: midiCh) 784 797 // Lit state is main-thread-only; update synchronously so the menubar ··· 799 812 /// the held note. Doesn't retrigger the note. 800 813 func updateTapPan(_ midiNote: UInt8, pan: UInt8) { 801 814 guard tapHeld.contains(midiNote) else { return } 802 - let midiCh: UInt8 = midiNote < UInt8(KeyboardIconRenderer.firstMidi) ? 9 : 0 815 + // Mirror startTapNote: drum routing follows the *visual* key, 816 + // not the played pitch. 817 + let visualNote = tapDisplayNote[midiNote] ?? midiNote 818 + let midiCh: UInt8 = visualNote < UInt8(KeyboardIconRenderer.firstMidi) ? 9 : 0 803 819 midi.sendCC(10, value: pan, channel: midiCh) 804 820 } 805 821 ··· 810 826 let visualNote = tapDisplayNote.removeValue(forKey: midiNote) ?? midiNote 811 827 let lingerVelocity = tapLinger.removeValue(forKey: midiNote) 812 828 let synthCh = tapNoteChannel.removeValue(forKey: midiNote) ?? channel(for: midiNote) 813 - let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 829 + // Drum routing follows the visual key (see startTapNote). 830 + let isDrum = visualNote < UInt8(KeyboardIconRenderer.firstMidi) 814 831 let midiCh: UInt8 = isDrum ? 9 : 0 815 832 // Drums are one-shot percussion: do NOT send synth.noteOff. Letting 816 833 // the sample play through is what makes rapid taps overlap correctly ··· 1161 1178 if !midiMode { synth.setPan(pan, channel: synthCh) } 1162 1179 midi.sendCC(10, value: pan, channel: 0) 1163 1180 if !midiMode { synth.noteOn(note, velocity: 100, channel: synthCh) } 1164 - midi.noteOn(note) 1181 + midi.noteOn(note, velocity: 100, channel: 0) 1165 1182 // The menubar piano renders a fixed C4–C5 window; the audio 1166 1183 // path plays at the user's full octave-shifted pitch. To keep 1167 1184 // the visual lit state honest, mark the *display* note (note ··· 1239 1256 heldLock.unlock() 1240 1257 let tapSnapshot = tapHeld 1241 1258 let tapChanSnapshot = tapNoteChannel 1259 + let tapDisplaySnapshot = tapDisplayNote 1242 1260 tapHeld.removeAll() 1243 1261 tapNoteChannel.removeAll() 1244 1262 tapDisplayNote.removeAll() ··· 1249 1267 } 1250 1268 for note in tapSnapshot { 1251 1269 let synthCh = tapChanSnapshot[note] ?? channel(for: note) 1252 - let isDrum = note < UInt8(KeyboardIconRenderer.firstMidi) 1270 + // Drum routing follows the visual key (see startTapNote). 1271 + let visualNote = tapDisplaySnapshot[note] ?? note 1272 + let isDrum = visualNote < UInt8(KeyboardIconRenderer.firstMidi) 1253 1273 let midiCh: UInt8 = isDrum ? 9 : 0 1254 1274 if !isDrum { 1255 1275 synth.noteOff(note, channel: synthCh)
+96 -201
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 128 128 private var midiSwitch: NSSwitch! 129 129 private var midiInlineLabel: NSTextField! 130 130 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack 131 - private var instrumentList: InstrumentListView! 132 131 private var instrumentReadout: NSTextField! 133 132 private var instrumentLabel: NSTextField! 134 133 private var instrumentTitleRow: NSStackView! ··· 161 160 private var crashSendButton: NSButton! 162 161 private var updateBanner: NSView! 163 162 private var updateLabel: NSTextField! 164 - private var waveformView: WaveformView! 163 + /// Layered substrate for the held-notes pills + chord cards. The 164 + /// MTL waveform that used to live inside this bezel has been 165 + /// retired; the housing stays for visual continuity (rounded 166 + /// dark recess) but holds only the held-notes pills (top) and 167 + /// chord candidate cards (bottom). 165 168 private var waveformBezel: NSView! 169 + private var metronome: MetronomeWidget! 166 170 167 171 deinit { 168 172 if let monitor = focusShortcutRecorderMonitor { ··· 289 293 // Spacer lives in the middle so the octave widget pins LEFT and 290 294 // the MIDI pair pins RIGHT. 291 295 titleRow.addArrangedSubview(titleSpacer) 296 + 297 + // Metronome — sits between the octave widget and the MIDI 298 + // switch. Custom-drawn analog body with a real swinging 299 + // needle (animates while playing) and a tiny `<` BPM `>` 300 + // stepper underneath, mirroring the octave widget's chevron 301 + // look. Click the body to toggle play; chevrons step BPM. 302 + metronome = MetronomeWidget() 303 + metronome.translatesAutoresizingMaskIntoConstraints = false 304 + metronome.tint = .white 305 + metronome.toolTip = "Metronome — space to start / stop" 306 + titleRow.addArrangedSubview(metronome) 307 + titleRow.setCustomSpacing(8, after: metronome) 292 308 293 309 // MIDI toggle — tucked into the title row instead of its own panel. 294 310 // Enabling MIDI also silences the local keyboard (notes route to the ··· 541 557 // border, uniform inner margin around the bars. Without the 542 558 // bezel the bars sit flush against the popover walls and feel 543 559 // unfinished. 544 - waveformView = WaveformView() 545 - waveformView.menuBand = menuBand 546 - waveformView.translatesAutoresizingMaskIntoConstraints = false 547 - // Click on the visualizer → toggle the floating play palette 548 - // (the "big overlay"). Same target the menubar mini-meter 549 - // routes to, so users discover the overlay from either entry 550 - // point. Settings-button toggle still works from its own row. 551 - let waveformClick = NSClickGestureRecognizer( 552 - target: self, 553 - action: #selector(waveformViewClicked(_:)) 554 - ) 555 - waveformView.addGestureRecognizer(waveformClick) 556 - 560 + // The popover's visualizer was retired — the InstrumentListView 561 + // (above) is the only visualizer left. The container below is 562 + // a transparent layout wrapper for the held-notes pills + chord 563 + // cards (no chrome, no background, no border) so the rows just 564 + // sit on the popover surface. 557 565 waveformBezel = NSView() 558 - waveformBezel.wantsLayer = true 559 - waveformBezel.layer?.cornerRadius = 6 560 - // Bezel substrate color is set per-appearance in 561 - // `applyAppearanceToVisualizer` (called from syncFromController); 562 - // start dark so first-paint before sync isn't a flash. 563 - waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 564 - waveformBezel.layer?.borderWidth = 1 565 - // Border color is set in `updateInstrumentReadout` so the 566 - // housing tracks the chosen voice's family hue. 567 566 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 568 - waveformBezel.addSubview(waveformView) 569 - let bezelInset: CGFloat = 5 570 - NSLayoutConstraint.activate([ 571 - waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 572 - waveformView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 573 - waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 574 - waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 575 - ]) 576 567 // Held-notes goes ABOVE the visualizer so the actively- 577 568 // sounding pitches read like a label on the meter housing. 578 569 // Build the floating-boxes container HERE so the ivar is ··· 598 589 // play palette uses, so the two surfaces feel identical. 599 590 stack.addArrangedSubview(waveformBezel) 600 591 waveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 601 - waveformBezel.heightAnchor.constraint(equalToConstant: 80).isActive = true 602 - waveformBezel.layer?.masksToBounds = false 592 + // Just tall enough to host the held-notes pills (22pt) + 593 + // chord cards (28pt) + a small gap. No visual chrome — 594 + // pure layout wrapper. 595 + waveformBezel.heightAnchor.constraint(equalToConstant: 60).isActive = true 603 596 waveformBezel.addSubview(heldNotesContainer) 604 597 NSLayoutConstraint.activate([ 605 598 heldNotesContainer.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), ··· 697 690 instrumentReadout.setContentHuggingPriority(.defaultHigh, for: .horizontal) 698 691 instrumentReadout.setContentCompressionResistancePriority(.required, for: .horizontal) 699 692 titleLeftSpacer.widthAnchor.constraint(equalTo: titleRightSpacer.widthAnchor).isActive = true 700 - stack.addArrangedSubview(instrumentTitleRow) 693 + // Instrument title moved out of the popover with the chooser 694 + // — the chooser cells in the floating panel are the only 695 + // place the active GM voice surfaces now. 701 696 702 697 // (held-notes floating-boxes container is built ABOVE the 703 698 // visualizer — see the block before `waveformBezel`.) ··· 707 702 // pending UX polish. Source files retained for future revival; 708 703 // the popover currently exposes only the General MIDI grid. 709 704 710 - instrumentList = InstrumentListView() 711 - instrumentList.translatesAutoresizingMaskIntoConstraints = false 712 - // Wire the controller so the grid can snapshot the synth tap ring 713 - // for the audio-reactive overlay (treats the 8×16 cells as a 714 - // low-res LED matrix sweeping the recent waveform). 715 - instrumentList.menuBand = menuBand 716 - instrumentList.onCommit = { [weak self] prog in 717 - self?.handleInstrumentCommit(prog) 718 - } 719 - instrumentList.onArrowKey = { [weak self] dir, isDown in 720 - self?.arrowsHint.setHighlight(direction: dir, on: isDown) 721 - } 722 - // Forward non-arrow keys through the controller's local-key 723 - // handler so notepat / Ableton letter keys still play notes 724 - // while the popover holds first-responder focus on the grid. 725 - instrumentList.onMusicKey = { [weak self] kc, isDown, isRepeat, flags in 726 - return self?.menuBand?.handleLocalKey( 727 - keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags 728 - ) ?? false 729 - } 730 - instrumentList.onHover = { [weak self] prog in 731 - guard let self = self else { return } 732 - // Hover plays a continuous preview note in the hovered program; 733 - // moving to another cell stops + restarts in the new program. 734 - self.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) 735 - // Live-preview the chrome too — chip backdrop, chip text, 736 - // and visualizer base color all retint to whatever cell is 737 - // under the cursor while dragging. When hover ends (prog == 738 - // nil) we snap back to the committed instrument. 739 - let safe: Int 740 - let nameForChip: String 741 - let famColor: NSColor 742 - if let p = prog { 743 - // Live hover: always show the GM cell under the cursor. 744 - safe = max(0, min(127, p)) 745 - nameForChip = GeneralMIDI.programNames[safe] 746 - famColor = InstrumentListView.colorForProgram(safe) 747 - } else if let m = self.menuBand { 748 - if m.instrumentBackend == .kpbj { 749 - safe = 0 750 - nameForChip = "−1 KPBJ" 751 - famColor = NSColor.systemOrange 752 - } else { 753 - safe = max(0, min(127, Int(m.melodicProgram))) 754 - nameForChip = GeneralMIDI.programNames[safe] 755 - famColor = InstrumentListView.colorForProgram(safe) 756 - } 757 - } else { 758 - return 759 - } 760 - // Funnel through the shared styler — assigning `.stringValue` 761 - // here would wipe the field's attributedStringValue (font + 762 - // shadow), snapping the title back to system black mid-drag. 763 - self.applyInstrumentReadoutStyle(title: nameForChip, famColor: famColor) 764 - // Don't retint the LED bezel during hover-drag while 765 - // MIDI mode is on — it stays accent-colored as a status 766 - // badge. 767 - if let m = self.menuBand, !m.midiMode { 768 - self.waveformView.setBaseColor(famColor) 769 - self.waveformBezel?.layer?.borderColor = 770 - famColor.withAlphaComponent(0.55).cgColor 771 - } 772 - } 773 - // Wrap the grid in a panel that adds an extra strip BELOW the 774 - // 8×16 cells where the arrow-keys hint glyph lives. The hint 775 - // is its own bottom-right ornament — never overlaying the 776 - // voice cells — so the whole assembly reads like a stepper- 777 - // button corner on a stereo's faceplate. 705 + // Instrument chooser was lifted out — it now lives in the 706 + // collapsed floating panel that pairs with the popover. The 707 + // popover holds settings + the QWERTY chassis below. 778 708 let palettePanel = NSView() 779 709 palettePanel.translatesAutoresizingMaskIntoConstraints = false 780 - palettePanel.addSubview(instrumentList) 781 710 782 711 // MacBook-style keyboard chassis behind the QWERTY map + 783 712 // arrow keys. Layer-painted rounded slab tinted to read as ··· 838 767 } 839 768 palettePanel.addSubview(qwertyMap) 840 769 NSLayoutConstraint.activate([ 841 - instrumentList.topAnchor.constraint(equalTo: palettePanel.topAnchor), 842 - instrumentList.leadingAnchor.constraint(equalTo: palettePanel.leadingAnchor), 843 - instrumentList.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor), 844 - instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight), 845 - 846 - // Deck wraps the keys + trackpad. Spans the full panel 847 - // width so the chassis reads as a real laptop deck under 848 - // the voice grid. Sits 4 pt below the instrument palette 849 - // (was 6) so the chassis reads as flush-mounted to the 850 - // grid above instead of floating with a wide gutter. 770 + // Deck wraps the keys + trackpad. Sits at the top of the 771 + // panel since the chooser that used to live above it is 772 + // now in the floating window. 851 773 keyboardDeck.leadingAnchor.constraint(equalTo: palettePanel.leadingAnchor), 852 774 keyboardDeck.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor), 853 - keyboardDeck.topAnchor.constraint(equalTo: instrumentList.bottomAnchor, constant: 4), 775 + keyboardDeck.topAnchor.constraint(equalTo: palettePanel.topAnchor), 854 776 keyboardDeck.bottomAnchor.constraint(equalTo: palettePanel.bottomAnchor), 855 777 856 778 // QWERTY map sits at the top of the chassis with a ··· 867 789 arrowsHint.trailingAnchor.constraint(equalTo: keyboardDeck.trailingAnchor, constant: -cornerInset), 868 790 arrowsHint.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: 0), 869 791 ]) 870 - stack.addArrangedSubview(palettePanel) 871 - palettePanel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 872 - palettePanel.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight + strip).isActive = true 792 + // QWERTY chassis + arrow cluster moved out of the popover — 793 + // they live under the chooser in the floating panel now. 794 + // The palettePanel + its subviews are still constructed 795 + // above so the .onChange handlers that poke `arrowsHint`, 796 + // `qwertyMap`, etc. don't crash; the panel just never makes 797 + // it into the popover view tree. 798 + _ = palettePanel 799 + // Keymap picker, focus / play-palette shortcut rows retired 800 + // from the popover — the liquid floating panel hosts the 801 + // qwerty map + chooser as an "expanded part of the 802 + // instrument," and the popover stays a music-theory surface 803 + // (held notes + chord cards above) plus app-meta (about / 804 + // language / quit below). Layout block + shortcut bindings 805 + // are still constructed above so syncFromController + recorder 806 + // wiring keep compiling, just never added to the stack. 807 + _ = layoutBlock 808 + _ = shortcutLabel 809 + _ = shortcuts 810 + _ = playPaletteRow 873 811 874 - // Layout block (built earlier, appended here so it sits below 875 - // the voice grid + arrow keys). 876 - stack.setCustomSpacing(8, after: palettePanel) 877 - stack.addArrangedSubview(layoutBlock.label) 878 - stack.addArrangedSubview(layoutBlock.picker) 879 - stack.addArrangedSubview(layoutBlock.hint) 880 - stack.addArrangedSubview(layoutBlock.link) 881 - stack.addArrangedSubview(shortcutLabel) 882 - stack.addArrangedSubview(shortcuts) 883 - stack.addArrangedSubview(focusShortcutStatusLabel) 884 - stack.addArrangedSubview(playPaletteRow) 885 - stack.addArrangedSubview(playPaletteShortcutStatusLabel) 886 - 887 - // No divider above the about/brand block — the palette + Layout 888 - // section above gives plenty of separation. Custom airspace 889 - // before the about block. 890 - stack.setCustomSpacing(14, after: playPaletteShortcutStatusLabel) 812 + stack.setCustomSpacing(14, after: waveformBezel) 891 813 892 814 // About + Crash logs in a side-by-side row. About has low hugging 893 815 // so it expands when the crash column is hidden (no reports) — ··· 1063 985 // no one can see. Patch contributed by Esteban Uribe. 1064 986 override func viewDidAppear() { 1065 987 super.viewDidAppear() 1066 - guard isViewLoaded, let menuBand = waveformView.menuBand else { return } 988 + guard isViewLoaded, let menuBand = self.menuBand else { return } 1067 989 syncFromController() 1068 990 applyVisualizerForMidiMode(menuBand.midiMode) 1069 - // Make the voice grid first responder on every popover open so 1070 - // arrow keys always step the selection — even before the user 1071 - // has clicked into the grid this session. 1072 - view.window?.makeFirstResponder(instrumentList) 991 + // Voice-grid focus moved out of the popover with the chooser 992 + // (now in the floating panel). Nothing to make first responder 993 + // here. 1073 994 // Refresh the Notepat mode button if a freshly-cached 1074 995 // favicon has landed since loadView() ran. One-shot observer 1075 996 // re-installs each time the popover appears so we don't leak ··· 1089 1010 super.viewDidDisappear() 1090 1011 stopFocusShortcutRecording(status: nil) 1091 1012 stopPlayPaletteShortcutRecording(status: nil) 1092 - waveformView.isLive = false 1093 1013 } 1094 1014 1095 - /// Drive the voice grid's selection from the on-screen arrow 1096 - /// keycaps as if the physical arrow key had been pressed — 1097 - /// `isDown` triggers the move + preview note, the matching `up` 1098 - /// commits the cell. Mirrors `InstrumentMapView.keyDown` / 1099 - /// `keyUp`'s logic so click-on-the-D-pad and arrow-key-on-the- 1100 - /// keyboard share one code path. 1015 + /// Drive the program selection from the on-screen arrow keycaps. 1016 + /// The visible chooser moved to the floating panel, so the popover 1017 + /// now drives the program directly via the controller — preview 1018 + /// while pressed, commit on release. 1101 1019 private func simulateArrow(direction dir: Int, isDown: Bool) { 1102 - guard let list = instrumentList else { return } 1020 + guard let m = menuBand else { return } 1021 + let cur = Int(m.effectiveMelodicProgram) 1022 + var next = cur 1023 + switch dir { 1024 + case 0: next = cur - 1 // ← 1025 + case 1: next = cur + 1 // → 1026 + case 2: next = cur + InstrumentListView.cols // ↓ 1027 + case 3: next = cur - InstrumentListView.cols // ↑ 1028 + default: return 1029 + } 1030 + next = max(0, min(127, next)) 1103 1031 if isDown { 1104 - let cur = Int(list.selectedProgram) 1105 - var next = cur 1106 - switch dir { 1107 - case 0: next = cur - 1 // ← 1108 - case 1: next = cur + 1 // → 1109 - case 2: next = cur + InstrumentListView.cols // ↓ 1110 - case 3: next = cur - InstrumentListView.cols // ↑ 1111 - default: return 1112 - } 1113 - next = max(0, min(127, next)) 1114 1032 arrowsHint.setHighlight(direction: dir, on: true) 1115 1033 if next != cur { 1116 - list.selectedProgram = UInt8(next) 1117 - list.onHover?(next) 1034 + m.setInstrumentPreview(UInt8(next)) 1118 1035 } 1119 1036 } else { 1120 1037 arrowsHint.setHighlight(direction: dir, on: false) 1121 - list.onHover?(nil) 1122 - list.onCommit?(Int(list.selectedProgram)) 1038 + m.setInstrumentPreview(nil) 1039 + m.setMelodicProgram(UInt8(cur)) 1123 1040 } 1124 1041 } 1125 1042 ··· 1239 1156 } 1240 1157 updateFocusShortcutControls() 1241 1158 updatePlayPaletteShortcutControls() 1242 - instrumentList.selectedProgram = n.melodicProgram 1243 1159 applyAppearanceToVisualizer() 1244 1160 updateInstrumentReadout() 1245 1161 // Keep the QWERTY layout's keymap + tint synced with the ··· 1257 1173 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 1258 1174 refreshCrashStatus() 1259 1175 refreshUpdateBanner() 1260 - // Waveform: live only when local synth is the audible path. 1261 - // Stays in the layout when MIDI mode is on; the palette 1262 - // visibility helper greys it out instead of collapsing. 1263 - waveformView.isHidden = false 1264 - // isLive is driven from viewDidAppear/viewDidDisappear so the 1265 - // display link only runs while the popover is actually on screen. 1266 1176 // Instrument palette: stays in the layout but greys out when 1267 1177 // MIDI mode owns the audio path. Same physical width either way. 1268 1178 applyInstrumentPaletteVisibility(midiMode: n.midiMode) ··· 1444 1354 // status badge ("MIDI" dot-matrix in system accent), so we 1445 1355 // skip the retint when MIDI is on. 1446 1356 if m.midiMode { 1447 - waveformView.setBaseColor(.controlAccentColor) 1448 1357 waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 1449 1358 .withAlphaComponent(0.55).cgColor 1450 1359 } else { 1451 - waveformView.setBaseColor(famColor) 1452 1360 waveformBezel?.layer?.borderColor = famColor 1453 1361 .withAlphaComponent(0.55).cgColor 1454 1362 } ··· 1550 1458 for sub in root.subviews { forceRedrawSubtree(sub) } 1551 1459 } 1552 1460 1553 - /// Flip the LED bezel + visualizer between dark-mode (LED-on-black 1554 - /// glow) and light-mode (ink-on-paper) substrates so the meter 1461 + /// Flip the held-notes/chord bezel between dark-mode (LED-on-black 1462 + /// glow) and light-mode (ink-on-paper) substrates so the housing 1555 1463 /// doesn't look like a black slab pasted onto a white popover. 1556 1464 private func applyAppearanceToVisualizer() { 1557 1465 let isDark = view.effectiveAppearance.bestMatch( 1558 1466 from: [.aqua, .darkAqua]) == .darkAqua 1559 - waveformView.setLightMode(!isDark) 1560 1467 if isDark { 1561 1468 waveformBezel?.layer?.backgroundColor = 1562 1469 NSColor(white: 0.06, alpha: 1.0).cgColor ··· 1819 1726 1820 1727 // MARK: - Actions 1821 1728 1729 + /// Programmatic toggle of the metronome's play/pause — wired from 1730 + /// the spacebar shortcut in AppDelegate's local key handler so 1731 + /// users can start/stop the swing without aiming the mouse. 1732 + func toggleMetronome() { 1733 + metronome?.isPlaying.toggle() 1734 + } 1735 + 1822 1736 @objc private func midiSwitchToggled(_ sender: NSSwitch) { 1823 1737 // Just toggle — don't run the heavy syncFromController. The switch 1824 1738 // already shows the user's intent; the loopback test (skipped on ··· 1879 1793 let dimmed: CGFloat = midiMode ? 0.35 : 1.0 1880 1794 instrumentSeparator.alphaValue = dimmed 1881 1795 instrumentTitleRow.alphaValue = dimmed 1882 - instrumentList.alphaValue = dimmed 1883 - // Waveform stays visible (no longer collapses), just stops 1884 - // ingesting samples — `isLive = false` (set by the caller) means 1885 - // the bars freeze at their last value rather than going dark. To 1886 - // convey "this is inactive," we fade it with the same alpha as 1887 - // the palette. `_ = animated` keeps the parameter signature 1796 + // The bezel housing dims with the rest of the palette; chord 1797 + // cards / held-notes pills inside it inherit the dim via their 1798 + // parent. `_ = animated` keeps the parameter signature 1888 1799 // compatible with existing call sites. 1889 - waveformView.alphaValue = dimmed 1800 + waveformBezel?.alphaValue = dimmed 1890 1801 _ = animated 1891 1802 } 1892 1803 ··· 1924 1835 updatePlayPaletteShortcutControls() 1925 1836 } 1926 1837 1927 - @objc private func waveformViewClicked(_ sender: NSClickGestureRecognizer) { 1928 - // Route through the same callback the explicit toggle button 1929 - // uses — keep one source of truth for "open big overlay" so 1930 - // future changes (e.g., suppress when already shown) only need 1931 - // to touch onPlayPaletteToggle, not multiple call sites. 1932 - onPlayPaletteToggle?() 1933 - updatePlayPaletteShortcutControls() 1934 - } 1935 1838 1936 1839 @objc private func playPaletteShortcutButtonClicked(_ sender: NSButton) { 1937 1840 if isRecordingPlayPaletteShortcut { ··· 1955 1858 updateSelfTestLabel(state: .unknown) 1956 1859 } 1957 1860 m.setMelodicProgram(UInt8(program)) 1958 - instrumentList.selectedProgram = UInt8(program) 1959 1861 updateInstrumentReadout() 1960 1862 debugLog("instrument commit prog=\(program) midiAutoOff=\(wasMidiOn)") 1961 1863 if wasMidiOn { ··· 1973 1875 // through onHover(nil) first which stops the preview cleanly. 1974 1876 } 1975 1877 1976 - /// Single source-of-truth wiring between `midiMode` and the 1977 - /// visualizer's three modes (live VU vs MIDI dot-matrix) plus 1978 - /// its base color (voice family vs system accent). Called from 1979 - /// every place that flips midiMode — keeps the meter from 1980 - /// getting stuck in a stale state when MIDI is auto-disabled by 1981 - /// picking a new voice. 1878 + /// Single source-of-truth wiring for the popover's MIDI-mode 1879 + /// visual state. The MTL meter that this used to drive is gone; 1880 + /// only the bezel border tint remains so MIDI mode still reads 1881 + /// as "accent-colored" while voice mode reads as family-colored. 1982 1882 func applyVisualizerForMidiMode(_ midiOn: Bool) { 1983 1883 guard let m = menuBand else { return } 1984 - waveformView.isLive = !midiOn 1985 1884 if midiOn { 1986 - waveformView.setDotMatrix(Self.midiDotPattern) 1987 - waveformView.setBaseColor(.controlAccentColor) 1988 1885 waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 1989 1886 .withAlphaComponent(0.55).cgColor 1990 1887 } else { 1991 - waveformView.setDotMatrix(nil) 1992 1888 let safe = max(0, min(127, Int(m.melodicProgram))) 1993 1889 let famColor = InstrumentListView.colorForProgram(safe) 1994 - waveformView.setBaseColor(famColor) 1995 1890 waveformBezel?.layer?.borderColor = famColor 1996 1891 .withAlphaComponent(0.55).cgColor 1997 1892 }
+315
slab/menuband/Sources/MenuBand/MetronomeWidget.swift
··· 1 + import AppKit 2 + 3 + /// Compact metronome widget for the popover title row: a custom-drawn 4 + /// metronome body + a real swinging needle (animates when playing), 5 + /// with a tiny BPM readout and `<` / `>` BPM-step arrows below. 6 + /// 7 + /// Tapping the body toggles play/pause; tapping the arrows steps BPM 8 + /// by 1 (default range 40…240). The actual click→sound wiring is up 9 + /// to whatever metronome engine eventually drives `isPlaying` and 10 + /// `bpm`; this view just owns the visuals + interaction. 11 + final class MetronomeWidget: NSView { 12 + /// External setters drive the visuals — both the BPM readout and 13 + /// the swing tempo. Setting `bpm` while playing reschedules the 14 + /// needle animation to match the new beat. 15 + var bpm: Int = 120 { 16 + didSet { 17 + bpm = max(Self.minBPM, min(Self.maxBPM, bpm)) 18 + updateBPMLabel() 19 + if abs(bpmSlider.doubleValue - Double(bpm)) > 0.5 { 20 + bpmSlider.doubleValue = Double(bpm) 21 + } 22 + if isPlaying { restartSwing() } 23 + } 24 + } 25 + 26 + /// Drives the needle animation. Off → needle freezes upright; 27 + /// on → needle swings ±25° at the current BPM. 28 + var isPlaying: Bool = false { 29 + didSet { 30 + guard isPlaying != oldValue else { return } 31 + isPlaying ? startSwing() : stopSwing() 32 + } 33 + } 34 + 35 + /// Tint color for the metronome body, needle, and arrows. The 36 + /// popover tints to white so the widget pops against the 37 + /// translucent title row. 38 + var tint: NSColor = .white { 39 + didSet { needsDisplay = true } 40 + } 41 + 42 + var onToggle: (() -> Void)? 43 + var onBPMChange: ((Int) -> Void)? 44 + 45 + private let bodyButton = NSButton() 46 + private let bpmLabel = NSTextField(labelWithString: "") 47 + private let bpmSlider = NSSlider() 48 + private let needleLayer = CAShapeLayer() 49 + 50 + private static let minBPM = 40 51 + private static let maxBPM = 240 52 + private static let bodySize = NSSize(width: 18, height: 20) 53 + private static let stepperWidth: CGFloat = 38 54 + private static let bodyToStepperGap: CGFloat = 4 55 + /// Needle pivots at the bottom-center of the body, swings between 56 + /// ±maxSwingAngle. 25° is roughly the visual swing of a real 57 + /// wind-up metronome's pendulum. 58 + private static let maxSwingAngle: CGFloat = 25.0 * .pi / 180.0 59 + 60 + override var intrinsicContentSize: NSSize { 61 + NSSize( 62 + width: Self.bodySize.width + Self.bodyToStepperGap + Self.stepperWidth, 63 + height: Self.bodySize.height 64 + ) 65 + } 66 + 67 + override init(frame frameRect: NSRect) { 68 + super.init(frame: frameRect) 69 + wantsLayer = true 70 + layer?.masksToBounds = false 71 + setFrameSize(intrinsicContentSize) 72 + buildSubviews() 73 + configureNeedleLayer() 74 + updateBPMLabel() 75 + } 76 + 77 + required init?(coder: NSCoder) { fatalError() } 78 + 79 + override var isFlipped: Bool { false } 80 + 81 + override var mouseDownCanMoveWindow: Bool { false } 82 + 83 + // MARK: - Layout 84 + 85 + private func buildSubviews() { 86 + // Body button — invisible NSButton overlaid on the metronome 87 + // body region so the system handles hit testing + click 88 + // feedback. The actual graphic is drawn in `draw(_:)`. 89 + bodyButton.isBordered = false 90 + bodyButton.title = "" 91 + bodyButton.target = self 92 + bodyButton.action = #selector(bodyClicked) 93 + bodyButton.translatesAutoresizingMaskIntoConstraints = false 94 + addSubview(bodyButton) 95 + 96 + bpmLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 8, weight: .bold) 97 + bpmLabel.textColor = .white 98 + bpmLabel.alignment = .center 99 + bpmLabel.translatesAutoresizingMaskIntoConstraints = false 100 + addSubview(bpmLabel) 101 + 102 + bpmSlider.minValue = Double(Self.minBPM) 103 + bpmSlider.maxValue = Double(Self.maxBPM) 104 + bpmSlider.doubleValue = Double(bpm) 105 + bpmSlider.controlSize = .mini 106 + bpmSlider.target = self 107 + bpmSlider.action = #selector(sliderChanged(_:)) 108 + bpmSlider.toolTip = "Tempo" 109 + bpmSlider.translatesAutoresizingMaskIntoConstraints = false 110 + addSubview(bpmSlider) 111 + 112 + NSLayoutConstraint.activate([ 113 + // Body sits flush left, vertically centered with the row. 114 + bodyButton.leadingAnchor.constraint(equalTo: leadingAnchor), 115 + bodyButton.centerYAnchor.constraint(equalTo: centerYAnchor), 116 + bodyButton.widthAnchor.constraint(equalToConstant: Self.bodySize.width), 117 + bodyButton.heightAnchor.constraint(equalToConstant: Self.bodySize.height), 118 + 119 + // BPM number floats above the slider, both anchored to the 120 + // right of the body. Compact stack so the whole widget 121 + // stays inside the row's vertical bounds. 122 + bpmLabel.leadingAnchor.constraint(equalTo: bodyButton.trailingAnchor, constant: Self.bodyToStepperGap), 123 + bpmLabel.trailingAnchor.constraint(equalTo: trailingAnchor), 124 + bpmLabel.topAnchor.constraint(equalTo: topAnchor), 125 + bpmLabel.heightAnchor.constraint(equalToConstant: 9), 126 + 127 + bpmSlider.leadingAnchor.constraint(equalTo: bpmLabel.leadingAnchor), 128 + bpmSlider.trailingAnchor.constraint(equalTo: bpmLabel.trailingAnchor), 129 + bpmSlider.topAnchor.constraint(equalTo: bpmLabel.bottomAnchor, constant: 1), 130 + bpmSlider.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), 131 + ]) 132 + } 133 + 134 + private func configureNeedleLayer() { 135 + // The body's drawing is in this view's `draw(_:)`. The needle 136 + // is a CAShapeLayer so it can rotate cheaply via implicit 137 + // animation. Hosted in a sublayer with anchor at bottom-center 138 + // so a rotation pivots around the hinge, not the geometric 139 + // mid. Body sits flush-left and vertically centered, so the 140 + // needle pivot's x = body.midX (= bodySize.width / 2), y = 141 + // body.minY + small inset. 142 + guard let layer = self.layer else { return } 143 + let body = bodyRect 144 + let pivotX = body.midX 145 + let pivotY = body.minY + 2 146 + let needleLength = Self.bodySize.height - 5 147 + let host = CALayer() 148 + host.bounds = NSRect(x: 0, y: 0, width: 4, height: needleLength) 149 + host.anchorPoint = NSPoint(x: 0.5, y: 0.0) // bottom-center 150 + host.position = NSPoint(x: pivotX, y: pivotY) 151 + 152 + let needlePath = NSBezierPath() 153 + needlePath.move(to: NSPoint(x: 2, y: 0)) 154 + needlePath.line(to: NSPoint(x: 2, y: needleLength)) 155 + needleLayer.path = needlePath.cgPath 156 + needleLayer.strokeColor = NSColor.white.cgColor 157 + needleLayer.lineWidth = 1.4 158 + needleLayer.lineCap = .round 159 + needleLayer.fillColor = nil 160 + needleLayer.frame = host.bounds 161 + // Counterweight bob at the tip — small filled disc so the 162 + // needle reads as a real metronome arm, not a tick mark. 163 + let bob = CAShapeLayer() 164 + let bobR: CGFloat = 1.6 165 + let bobPath = NSBezierPath(ovalIn: NSRect( 166 + x: 2 - bobR, 167 + y: needleLength - bobR, 168 + width: bobR * 2, 169 + height: bobR * 2 170 + )) 171 + bob.path = bobPath.cgPath 172 + bob.fillColor = NSColor.white.cgColor 173 + bob.frame = host.bounds 174 + host.addSublayer(needleLayer) 175 + host.addSublayer(bob) 176 + layer.addSublayer(host) 177 + self.needleHost = host 178 + } 179 + 180 + private weak var needleHost: CALayer? 181 + private var tickTimer: Timer? 182 + /// Pre-loaded system click sound. NSSound("Tink") is the closest 183 + /// thing macOS has to a wood-block click; pre-loading dodges the 184 + /// per-tick disk hit. Falls back silently if the sound is missing 185 + /// (theme override etc.) — the visual swing still works. 186 + private static let tickSound: NSSound? = NSSound(named: NSSound.Name("Tink")) 187 + 188 + // MARK: - Actions 189 + 190 + @objc private func bodyClicked() { 191 + isPlaying.toggle() 192 + onToggle?() 193 + } 194 + 195 + @objc private func sliderChanged(_ sender: NSSlider) { 196 + let next = Int(sender.doubleValue.rounded()) 197 + guard next != bpm else { return } 198 + bpm = next 199 + onBPMChange?(bpm) 200 + } 201 + 202 + private func updateBPMLabel() { 203 + bpmLabel.stringValue = String(bpm) 204 + } 205 + 206 + // MARK: - Drawing 207 + 208 + override func draw(_ dirtyRect: NSRect) { 209 + super.draw(dirtyRect) 210 + let body = bodyRect 211 + // Trapezoidal metronome body — wide at the bottom, narrow at 212 + // the top, like a pyramid wind-up case. 213 + let path = NSBezierPath() 214 + let topInset: CGFloat = body.width * 0.30 215 + path.move(to: NSPoint(x: body.minX + topInset, y: body.maxY)) 216 + path.line(to: NSPoint(x: body.maxX - topInset, y: body.maxY)) 217 + path.line(to: NSPoint(x: body.maxX, y: body.minY)) 218 + path.line(to: NSPoint(x: body.minX, y: body.minY)) 219 + path.close() 220 + path.lineWidth = 1.2 221 + tint.setStroke() 222 + path.stroke() 223 + // Base — short horizontal line at the very bottom so the body 224 + // reads as sitting on a stand. 225 + NSColor.clear.setFill() 226 + } 227 + 228 + private var bodyRect: NSRect { 229 + let y = (bounds.height - Self.bodySize.height) / 2 230 + return NSRect(x: 0, y: y, width: Self.bodySize.width, height: Self.bodySize.height) 231 + } 232 + 233 + // MARK: - Needle animation 234 + 235 + private static let swingAnimationKey = "needleSwing" 236 + 237 + private func startSwing() { 238 + restartSwing() 239 + } 240 + 241 + private func stopSwing() { 242 + needleHost?.removeAnimation(forKey: Self.swingAnimationKey) 243 + // Snap needle upright on stop — implicit transaction handles 244 + // the smooth interpolation back to angle 0. 245 + needleHost?.setAffineTransform(.identity) 246 + tickTimer?.invalidate() 247 + tickTimer = nil 248 + } 249 + 250 + private func restartSwing() { 251 + guard let host = needleHost else { return } 252 + host.removeAnimation(forKey: Self.swingAnimationKey) 253 + // One full swing = beat-tick-tock; a 120 BPM tempo means two 254 + // beats per second, so a left↔right cycle every 1.0 s. Period 255 + // = 60 / bpm * 2 (one full back-and-forth = two beats). 256 + let period = 60.0 / Double(bpm) * 2.0 257 + let anim = CAKeyframeAnimation(keyPath: "transform.rotation.z") 258 + anim.values = [0, Self.maxSwingAngle, 0, -Self.maxSwingAngle, 0] 259 + anim.keyTimes = [0, 0.25, 0.5, 0.75, 1] 260 + anim.duration = period 261 + anim.repeatCount = .infinity 262 + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 263 + host.add(anim, forKey: Self.swingAnimationKey) 264 + 265 + // Audible tick aligned to the swing extremes — one click per 266 + // beat (= twice per swing period). Timer fires 0.25·period 267 + // after start so the first click lands when the needle is 268 + // first at full extension, then every period/2 thereafter. 269 + tickTimer?.invalidate() 270 + let beatInterval = period / 2.0 271 + let timer = Timer(timeInterval: beatInterval, repeats: true) { [weak self] _ in 272 + self?.playClick() 273 + } 274 + timer.tolerance = beatInterval * 0.05 275 + // Fire the first beat aligned to the needle peak (a quarter- 276 + // period after the swing starts). RunLoop.add with `.common` 277 + // so menu/event tracking doesn't suspend the ticks. 278 + timer.fireDate = Date(timeIntervalSinceNow: period * 0.25) 279 + RunLoop.main.add(timer, forMode: .common) 280 + tickTimer = timer 281 + } 282 + 283 + private func playClick() { 284 + // NSSound.play() restarts from the beginning if already mid- 285 + // playback — exactly what we want for rapid retriggers. 286 + Self.tickSound?.stop() 287 + Self.tickSound?.play() 288 + } 289 + } 290 + 291 + private extension NSBezierPath { 292 + /// `CGPath` bridge — AppKit's NSBezierPath has a `cgPath` getter 293 + /// only on macOS 14+. Hand-roll the conversion so this widget 294 + /// keeps working on macOS 11+. 295 + var cgPath: CGPath { 296 + let path = CGMutablePath() 297 + var points = [NSPoint](repeating: .zero, count: 3) 298 + for i in 0..<elementCount { 299 + let type = element(at: i, associatedPoints: &points) 300 + switch type { 301 + case .moveTo: 302 + path.move(to: points[0]) 303 + case .lineTo: 304 + path.addLine(to: points[0]) 305 + case .curveTo: 306 + path.addCurve(to: points[2], control1: points[0], control2: points[1]) 307 + case .closePath: 308 + path.closeSubpath() 309 + default: 310 + continue 311 + } 312 + } 313 + return path 314 + } 315 + }
+241 -202
slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
··· 15 15 16 16 private weak var menuBand: MenuBandController? 17 17 private let contentContainer = NSView() 18 - private let waveformContainer = NSView() 19 - private let waveformClipView = NSView() 20 - private let waveformView = WaveformView() 21 - private let heldNotesContainer = NSView() 22 - private let heldNotesStack = NSStackView() 23 - private let instrumentRow = NSView() 24 - private let instrumentArrows = ArrowKeysIndicator() 25 - private let instrumentLabel = NSTextField(labelWithString: "") 18 + /// GM instrument chooser — embedded as the audio-reactive 19 + /// bee-vision picker in the collapsed floating panel. Held-note 20 + /// pills + chord-candidate cards live in the popover and 21 + /// expanded view only; the collapsed strip stays compact. 22 + private let instrumentList = InstrumentListView() 23 + /// QWERTY keymap visualization — moved out of the popover so 24 + /// the user can see which physical keys play which notes while 25 + /// the chooser is open. Lit cells reflect held keys. 26 + private let qwertyMap = QwertyLayoutView() 27 + /// Four-arrow cluster below the chooser — preview while held, 28 + /// commit on release. Mirrors the popover's old arrows hint 29 + /// position (under the keyboard) one level down. 30 + private let arrowsCluster = ArrowKeysIndicator() 31 + /// Notepat / Ableton mode picker — moved out of the popover so 32 + /// the liquid panel reads as the operational/physical "extended 33 + /// instrument," and the popover stays a music-theory surface. 34 + private let modeStack = NSStackView() 35 + private var modeButtons: [NSButton] = [] 36 + /// Compact "About" row at the panel's bottom — Menu Band 37 + /// description + aesthetic.computer link. Moved out of the 38 + /// popover so the popover stays a music-theory surface. 39 + private let aboutBody = NSTextField(wrappingLabelWithString: "") 40 + private let aboutLinkButton = NSButton() 26 41 private var trackingArea: NSTrackingArea? 27 42 private weak var paletteGlassView: NSView? 28 - private weak var waveformGlassView: NSView? 29 - private weak var instrumentRowGlassView: NSView? 30 43 31 44 var onHoverChanged: ((Bool) -> Void)? 32 45 var onStepBackward: (() -> Void)? ··· 34 47 var onStepUp: (() -> Void)? 35 48 var onStepDown: (() -> Void)? 36 49 37 - private static let waveformHeight: CGFloat = 64 38 - private static let heldNotesRowHeight: CGFloat = 16 39 - private static let instrumentRowHeight: CGFloat = 22 50 + private static let arrowsRowHeight: CGFloat = 34 51 + private static let modeRowHeight: CGFloat = 22 52 + private static let aboutRowHeight: CGFloat = 36 53 + private static let edgePadding: CGFloat = 6 54 + private static let rowGap: CGFloat = 4 55 + /// Reserved at the top — hosts the chord-candidate row above 56 + /// the chooser. 30pt fits the FloatingChordCandidateCard's 57 + /// intrinsic height. 58 + private static let topInset: CGFloat = 6 59 + /// Reserved at the bottom so the arrows cluster doesn't sit 60 + /// under the bottom-leading fullscreen toggle button. 61 + private static let bottomInset: CGFloat = 28 40 62 41 63 init(menuBand: MenuBandController) { 42 64 self.menuBand = menuBand ··· 46 68 layer?.masksToBounds = true 47 69 48 70 contentContainer.translatesAutoresizingMaskIntoConstraints = false 49 - waveformView.menuBand = menuBand 50 - waveformView.translatesAutoresizingMaskIntoConstraints = false 51 - waveformView.setSurfaceStyle(.standard) 52 71 53 - waveformContainer.wantsLayer = true 54 - waveformContainer.translatesAutoresizingMaskIntoConstraints = false 55 - waveformContainer.layer?.cornerRadius = 8 56 - waveformContainer.layer?.masksToBounds = false 72 + instrumentList.menuBand = menuBand 73 + instrumentList.translatesAutoresizingMaskIntoConstraints = false 74 + instrumentList.onCommit = { [weak self] prog in 75 + guard let self = self, let m = self.menuBand else { return } 76 + if m.midiMode { m.toggleMIDIMode() } 77 + m.setMelodicProgram(UInt8(prog)) 78 + self.refresh() 79 + } 80 + instrumentList.onHover = { [weak self] prog in 81 + self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) 82 + self?.refresh() 83 + } 84 + instrumentList.onMusicKey = { [weak self] kc, isDown, isRepeat, flags in 85 + return self?.menuBand?.handleLocalKey( 86 + keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags 87 + ) ?? false 88 + } 57 89 58 - waveformClipView.wantsLayer = true 59 - waveformClipView.translatesAutoresizingMaskIntoConstraints = false 60 - waveformClipView.layer?.cornerRadius = 8 61 - waveformClipView.layer?.masksToBounds = true 90 + qwertyMap.translatesAutoresizingMaskIntoConstraints = false 91 + qwertyMap.scale = 1.0 92 + qwertyMap.keymap = menuBand.keymap 93 + qwertyMap.onKey = { [weak self] kc, isDown in 94 + _ = self?.menuBand?.handleLocalKey( 95 + keyCode: kc, isDown: isDown, isRepeat: false, flags: [] 96 + ) 97 + } 62 98 63 - heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 64 - heldNotesStack.orientation = .horizontal 65 - heldNotesStack.alignment = .centerY 66 - heldNotesStack.spacing = 4 67 - heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 68 - 69 - instrumentRow.translatesAutoresizingMaskIntoConstraints = false 70 - instrumentRow.wantsLayer = true 71 - instrumentRow.layer?.cornerRadius = 7 72 - if #available(macOS 10.15, *) { 73 - instrumentRow.layer?.cornerCurve = .continuous 99 + modeStack.orientation = .horizontal 100 + modeStack.alignment = .centerY 101 + modeStack.spacing = 6 102 + modeStack.translatesAutoresizingMaskIntoConstraints = false 103 + let modeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) 104 + let modeSpecs: [(label: String, image: NSImage?, tag: Int)] = [ 105 + ("Notepat", 106 + NotepatFavicon.image 107 + ?? NSImage(systemSymbolName: "keyboard", accessibilityDescription: "Notepat")? 108 + .withSymbolConfiguration(modeSymbolConfig), 109 + 0), 110 + ("Ableton", AbletonLogo.image(height: 11), 1), 111 + ] 112 + for (idx, spec) in modeSpecs.enumerated() { 113 + let b = NSButton(title: spec.label, target: self, 114 + action: #selector(modeButtonClicked(_:))) 115 + b.tag = spec.tag 116 + b.bezelStyle = .recessed 117 + b.setButtonType(.pushOnPushOff) 118 + b.controlSize = .small 119 + b.imagePosition = .imageLeading 120 + b.imageHugsTitle = true 121 + b.image = spec.image 122 + b.translatesAutoresizingMaskIntoConstraints = false 123 + modeButtons.append(b) 124 + modeStack.addArrangedSubview(b) 125 + // "?" help button immediately after the Notepat (idx 0) 126 + // button — opens the keymaps paper so the user can read 127 + // why notepat looks the way it does. 128 + if idx == 0 { 129 + let helpConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) 130 + let help = NSButton() 131 + help.image = NSImage(systemSymbolName: "questionmark.circle", 132 + accessibilityDescription: "Why this layout?")? 133 + .withSymbolConfiguration(helpConfig) 134 + help.imagePosition = .imageOnly 135 + help.bezelStyle = .recessed 136 + help.controlSize = .small 137 + help.toolTip = "Why this layout?" 138 + help.target = self 139 + help.action = #selector(whyKeymapClicked(_:)) 140 + help.translatesAutoresizingMaskIntoConstraints = false 141 + modeStack.addArrangedSubview(help) 142 + } 74 143 } 75 - instrumentArrows.translatesAutoresizingMaskIntoConstraints = false 76 - instrumentArrows.setContentHuggingPriority(.required, for: .horizontal) 77 - instrumentArrows.setContentCompressionResistancePriority(.required, for: .horizontal) 78 - instrumentArrows.displayMode = .horizontalPair 79 - instrumentArrows.style = .prominent 80 - instrumentArrows.toolTip = "Change instrument" 81 - instrumentArrows.onClick = { [weak self] direction, isDown in 144 + 145 + arrowsCluster.translatesAutoresizingMaskIntoConstraints = false 146 + arrowsCluster.displayMode = .cluster 147 + arrowsCluster.style = .prominent 148 + arrowsCluster.toolTip = "Step instruments — ←/→ one at a time, ↑/↓ by row" 149 + arrowsCluster.onClick = { [weak self] direction, isDown in 82 150 guard let self, isDown else { return } 83 151 switch direction { 84 152 case 0: ··· 94 162 } 95 163 } 96 164 97 - instrumentLabel.translatesAutoresizingMaskIntoConstraints = false 98 - instrumentLabel.lineBreakMode = .byTruncatingTail 99 - instrumentLabel.drawsBackground = false 100 - instrumentLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 165 + // About row — bold "Menu Band" lead + secondary copy + 166 + // aesthetic.computer chip link. Replicates the popover's 167 + // about block in compact form. 168 + aboutBody.font = NSFont.systemFont(ofSize: 10) 169 + aboutBody.textColor = .secondaryLabelColor 170 + aboutBody.maximumNumberOfLines = 2 171 + aboutBody.lineBreakMode = .byTruncatingTail 172 + aboutBody.translatesAutoresizingMaskIntoConstraints = false 173 + let aboutText = NSMutableAttributedString() 174 + let bodyFont = NSFont.systemFont(ofSize: 10) 175 + let boldFont = NSFont.systemFont(ofSize: 10, weight: .bold) 176 + aboutText.append(NSAttributedString( 177 + string: "Menu Band", 178 + attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor])) 179 + aboutText.append(NSAttributedString( 180 + string: " — your menubar piano, an instrument woven into ", 181 + attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor])) 182 + aboutBody.attributedStringValue = aboutText 183 + 184 + let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) 185 + let acTitle = NSAttributedString( 186 + string: "aesthetic.computer", 187 + attributes: [ 188 + .font: NSFont.systemFont(ofSize: 10, weight: .semibold), 189 + .foregroundColor: acPurple, 190 + ]) 191 + aboutLinkButton.attributedTitle = acTitle 192 + aboutLinkButton.bezelStyle = .recessed 193 + aboutLinkButton.controlSize = .small 194 + aboutLinkButton.translatesAutoresizingMaskIntoConstraints = false 195 + aboutLinkButton.target = self 196 + aboutLinkButton.action = #selector(openAestheticClicked(_:)) 197 + aboutLinkButton.toolTip = "https://aesthetic.computer" 101 198 102 199 addSubview(contentContainer) 103 - contentContainer.addSubview(waveformContainer) 104 - contentContainer.addSubview(heldNotesContainer) 105 - contentContainer.addSubview(instrumentRow) 106 - waveformContainer.addSubview(waveformClipView) 107 - waveformClipView.addSubview(waveformView) 108 - heldNotesContainer.addSubview(heldNotesStack) 109 - instrumentRow.addSubview(instrumentArrows) 110 - instrumentRow.addSubview(instrumentLabel) 200 + contentContainer.addSubview(instrumentList) 201 + contentContainer.addSubview(qwertyMap) 202 + contentContainer.addSubview(arrowsCluster) 203 + contentContainer.addSubview(modeStack) 204 + contentContainer.addSubview(aboutBody) 205 + contentContainer.addSubview(aboutLinkButton) 111 206 installLiquidGlassBackgrounds() 112 207 208 + // Panel widens to fit either the chooser or the keyboard 209 + // row (qwerty + gap + arrows cluster), whichever is larger. 210 + let chooserRowWidth = Self.edgePadding + InstrumentListView.preferredWidth + Self.edgePadding 211 + let arrowsClusterWidth: CGFloat = 46 212 + let keyboardRowWidth = Self.edgePadding + QwertyLayoutView.intrinsicSize.width 213 + + Self.rowGap + arrowsClusterWidth + Self.edgePadding 214 + let totalWidth = max(chooserRowWidth, keyboardRowWidth) 215 + 113 216 NSLayoutConstraint.activate([ 114 - widthAnchor.constraint(equalToConstant: KeyboardIconRenderer.imageSize.width), 217 + widthAnchor.constraint(equalToConstant: totalWidth), 115 218 contentContainer.leadingAnchor.constraint(equalTo: leadingAnchor), 116 219 contentContainer.trailingAnchor.constraint(equalTo: trailingAnchor), 117 220 contentContainer.topAnchor.constraint(equalTo: topAnchor), 118 221 contentContainer.bottomAnchor.constraint(equalTo: bottomAnchor), 119 222 120 - waveformContainer.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 121 - waveformContainer.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 122 - waveformContainer.topAnchor.constraint(equalTo: contentContainer.topAnchor), 123 - waveformContainer.heightAnchor.constraint(equalToConstant: Self.waveformHeight), 223 + instrumentList.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding), 224 + instrumentList.topAnchor.constraint(equalTo: contentContainer.topAnchor, constant: Self.topInset), 225 + instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth), 226 + instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight), 124 227 125 - waveformClipView.leadingAnchor.constraint(equalTo: waveformContainer.leadingAnchor, constant: 5), 126 - waveformClipView.trailingAnchor.constraint(equalTo: waveformContainer.trailingAnchor, constant: -5), 127 - waveformClipView.topAnchor.constraint(equalTo: waveformContainer.topAnchor, constant: 5), 128 - waveformClipView.bottomAnchor.constraint(equalTo: waveformContainer.bottomAnchor, constant: -5), 228 + // Qwerty + arrows row centered horizontally in the panel 229 + // — looked too left-shoved when flush against the leading 230 + // edge. The cluster (qwerty + gap + arrows) is treated 231 + // as one unit so it sits balanced under the chooser. 232 + qwertyMap.topAnchor.constraint(equalTo: instrumentList.bottomAnchor, constant: Self.rowGap), 233 + qwertyMap.widthAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.width), 234 + qwertyMap.heightAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.height), 235 + qwertyMap.leadingAnchor.constraint( 236 + equalTo: contentContainer.leadingAnchor, 237 + constant: (totalWidth - QwertyLayoutView.intrinsicSize.width - Self.rowGap - arrowsClusterWidth) / 2 238 + ), 129 239 130 - waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 131 - waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 132 - waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 133 - waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 240 + arrowsCluster.leadingAnchor.constraint(equalTo: qwertyMap.trailingAnchor, constant: Self.rowGap), 241 + arrowsCluster.bottomAnchor.constraint(equalTo: qwertyMap.bottomAnchor), 242 + arrowsCluster.heightAnchor.constraint(equalToConstant: Self.arrowsRowHeight), 134 243 135 - heldNotesContainer.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 136 - heldNotesContainer.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 137 - heldNotesContainer.topAnchor.constraint(equalTo: waveformContainer.bottomAnchor, constant: 2), 138 - heldNotesContainer.heightAnchor.constraint(equalToConstant: Self.heldNotesRowHeight), 244 + // Mode picker (Notepat / Ableton) sits below the qwerty 245 + // row. Centered horizontally; the about row beneath it 246 + // pads the panel's bottom-leading fullscreen toggle. 247 + modeStack.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: Self.rowGap), 248 + modeStack.centerXAnchor.constraint(equalTo: contentContainer.centerXAnchor), 249 + modeStack.heightAnchor.constraint(equalToConstant: Self.modeRowHeight), 139 250 140 - heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 141 - heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 251 + // About row — wrapped Menu Band description on one line, 252 + // aesthetic.computer link on the next. Pinned at the 253 + // bottom inset so the fullscreen button stays visible 254 + // bottom-leading. 255 + aboutBody.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32), 256 + aboutBody.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor, constant: -Self.edgePadding), 257 + aboutBody.topAnchor.constraint(equalTo: modeStack.bottomAnchor, constant: Self.rowGap), 142 258 143 - instrumentRow.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), 144 - instrumentRow.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), 145 - instrumentRow.topAnchor.constraint(equalTo: heldNotesContainer.bottomAnchor, constant: 2), 146 - instrumentRow.heightAnchor.constraint(equalToConstant: Self.instrumentRowHeight), 147 - instrumentRow.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -2), 148 - 149 - instrumentArrows.leadingAnchor.constraint(equalTo: instrumentRow.leadingAnchor, constant: 6), 150 - instrumentArrows.centerYAnchor.constraint(equalTo: instrumentRow.centerYAnchor), 151 - 152 - instrumentLabel.leadingAnchor.constraint(equalTo: instrumentArrows.trailingAnchor, constant: 6), 153 - instrumentLabel.centerYAnchor.constraint(equalTo: instrumentRow.centerYAnchor), 154 - instrumentLabel.trailingAnchor.constraint(equalTo: instrumentRow.trailingAnchor, constant: -8), 259 + aboutLinkButton.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32), 260 + aboutLinkButton.topAnchor.constraint(equalTo: aboutBody.bottomAnchor, constant: 2), 261 + aboutLinkButton.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -Self.edgePadding), 155 262 ]) 156 263 157 264 refresh() ··· 192 299 func refresh() { 193 300 guard let menuBand else { return } 194 301 let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 195 - waveformView.setLightMode(!isDark) 196 302 197 303 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 198 304 let familyColor = menuBand.midiMode 199 305 ? NSColor.controlAccentColor 200 306 : InstrumentListView.colorForProgram(safe) 201 307 202 - let waveformBackground = isDark 203 - ? NSColor(white: 0.06, alpha: 1.0) 204 - : NSColor(white: 0.82, alpha: 1.0) 205 - let glassWaveformBackground = isDark 206 - ? NSColor.white.withAlphaComponent(0.04) 207 - : NSColor.white.withAlphaComponent(0.18) 208 - let instrumentRowBackground = isDark 209 - ? NSColor.white.withAlphaComponent(0.08) 210 - : NSColor.white.withAlphaComponent(0.3) 211 - waveformContainer.layer?.backgroundColor = NSColor.clear.cgColor 212 - waveformClipView.layer?.backgroundColor = Self.shouldUseLiquidGlass 213 - ? glassWaveformBackground.cgColor 214 - : waveformBackground.cgColor 215 - waveformContainer.layer?.borderWidth = 1 216 - waveformContainer.layer?.borderColor = familyColor.withAlphaComponent( 217 - Self.shouldUseLiquidGlass ? 0.22 : 0.55 218 - ).cgColor 219 - instrumentRow.layer?.backgroundColor = Self.shouldUseLiquidGlass 220 - ? NSColor.clear.cgColor 221 - : instrumentRowBackground.cgColor 222 - instrumentRow.layer?.borderWidth = 1 223 - instrumentRow.layer?.borderColor = familyColor.withAlphaComponent( 224 - Self.shouldUseLiquidGlass ? 0.22 : 0.35 225 - ).cgColor 226 - instrumentLabel.textColor = isDark ? .white : .black 227 - instrumentArrows.accentColor = familyColor 228 - instrumentArrows.isDarkAppearance = isDark 308 + // Follow the *effective* program (preview ?? committed) so the 309 + // bee-vision typographic center shifts live during hover-drag 310 + // through the grid and during arrow-key stepping. Without this 311 + // the giant selected number stays anchored to the committed 312 + // voice while the preview note plays a different program. 313 + instrumentList.selectedProgram = menuBand.effectiveMelodicProgram 229 314 230 - if menuBand.midiMode { 231 - waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 232 - waveformView.setBaseColor(.controlAccentColor) 233 - } else { 234 - waveformView.setDotMatrix(nil) 235 - waveformView.setBaseColor(familyColor) 236 - } 315 + arrowsCluster.accentColor = familyColor 316 + arrowsCluster.isDarkAppearance = isDark 237 317 238 - let shadow = NSShadow() 239 - shadow.shadowColor = familyColor.withAlphaComponent(isDark ? 0.9 : 0.55) 240 - shadow.shadowOffset = NSSize(width: 0, height: -1) 241 - shadow.shadowBlurRadius = 3 242 - let titleFont: NSFont = { 243 - if let descriptor = AppDelegate.ywftBoldDescriptor, 244 - let font = NSFont(descriptor: descriptor, size: 14), 245 - font.familyName == "YWFT Processing" { 246 - return font 247 - } 248 - return NSFont.systemFont(ofSize: 14, weight: .black) 249 - }() 250 - instrumentLabel.attributedStringValue = NSAttributedString( 251 - string: GeneralMIDI.programNames[safe], 252 - attributes: [ 253 - .font: titleFont, 254 - .foregroundColor: isDark ? NSColor.white : NSColor.black, 255 - .shadow: shadow, 256 - ] 257 - ) 318 + qwertyMap.keymap = menuBand.keymap 319 + qwertyMap.voiceColor = familyColor 320 + qwertyMap.litKeyCodes = menuBand.heldKeyCodes() 258 321 259 - for view in heldNotesStack.arrangedSubviews { 260 - heldNotesStack.removeArrangedSubview(view) 261 - view.removeFromSuperview() 262 - } 263 - for name in menuBand.heldNoteNames() { 264 - heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, color: familyColor)) 322 + // Mirror the keymap selection on the mode-picker buttons. 323 + let activeTag = (menuBand.keymap == .ableton) ? 1 : 0 324 + for button in modeButtons { 325 + button.state = (button.tag == activeTag) ? .on : .off 265 326 } 266 327 267 328 if Self.shouldUseLiquidGlass, #available(macOS 26.0, *) { 268 - let paletteTint = familyColor.withAlphaComponent(menuBand.midiMode ? 0.20 : 0.16) 329 + // Tinting is disabled for the collapsed view — even a 330 + // normalized hue tint perceptibly shifted the glass blur 331 + // between voices. Leaving NSGlassEffectView at its 332 + // default style keeps the panel visually identical from 333 + // open to close, no matter which instrument is active. 269 334 (paletteGlassView as? NSGlassEffectView)?.style = .clear 270 - (paletteGlassView as? NSGlassEffectView)?.tintColor = paletteTint 271 - (waveformGlassView as? NSGlassEffectView)?.style = .clear 272 - (waveformGlassView as? NSGlassEffectView)?.tintColor = paletteTint.withAlphaComponent(0.55) 273 - (instrumentRowGlassView as? NSGlassEffectView)?.style = .clear 274 - (instrumentRowGlassView as? NSGlassEffectView)?.tintColor = paletteTint.withAlphaComponent(0.42) 275 335 layer?.backgroundColor = NSColor.clear.cgColor 276 336 } else { 277 337 layer?.backgroundColor = (isDark ··· 280 340 } 281 341 } 282 342 283 - func setLive(_ isLive: Bool) { 284 - waveformView.isLive = isLive 343 + @objc private func openAestheticClicked(_ sender: NSButton) { 344 + if let url = URL(string: "https://aesthetic.computer") { 345 + NSWorkspace.shared.open(url) 346 + } 285 347 } 286 348 287 - private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 288 - let box = NSView() 289 - box.translatesAutoresizingMaskIntoConstraints = false 290 - box.wantsLayer = true 291 - box.layer?.cornerRadius = 4 292 - box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 293 - 294 - let label = NSTextField(labelWithString: name) 295 - label.translatesAutoresizingMaskIntoConstraints = false 296 - label.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .heavy) 297 - label.textColor = .black 298 - box.addSubview(label) 349 + @objc private func whyKeymapClicked(_ sender: NSButton) { 350 + // Same fallback chain as the popover's whyKeymapButton — 351 + // bundled PDF first (offline-friendly), then the public URL. 352 + if let url = Bundle.module.url( 353 + forResource: "keymaps-social-software-26-arxiv", 354 + withExtension: "pdf") 355 + { 356 + NSWorkspace.shared.open(url) 357 + return 358 + } 359 + if let url = URL(string: 360 + "https://papers.aesthetic.computer/keymaps-social-software-26-arxiv.pdf") { 361 + NSWorkspace.shared.open(url) 362 + } 363 + } 299 364 300 - NSLayoutConstraint.activate([ 301 - label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 302 - label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 303 - label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 304 - label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 305 - ]) 306 - return box 365 + @objc private func modeButtonClicked(_ sender: NSButton) { 366 + guard let menuBand else { return } 367 + let next: Keymap = (sender.tag == 1) ? .ableton : .notepat 368 + if menuBand.keymap != next { 369 + menuBand.keymap = next 370 + } 371 + // Manual radio behaviour: only the clicked button stays .on. 372 + for button in modeButtons { 373 + button.state = (button == sender) ? .on : .off 374 + } 307 375 } 308 376 309 377 private func installLiquidGlassBackgrounds() { ··· 320 388 paletteGlassView.bottomAnchor.constraint(equalTo: bottomAnchor), 321 389 ]) 322 390 self.paletteGlassView = paletteGlassView 323 - 324 - self.waveformGlassView = installGlassBackground( 325 - matchedTo: waveformContainer, 326 - below: waveformContainer, 327 - cornerRadius: 8 328 - ) 329 - self.instrumentRowGlassView = installGlassBackground( 330 - matchedTo: instrumentRow, 331 - below: instrumentRow, 332 - cornerRadius: 7 333 - ) 334 391 } 335 - 336 - @available(macOS 26.0, *) 337 - private func installGlassBackground(matchedTo target: NSView, 338 - below anchor: NSView, 339 - cornerRadius: CGFloat) -> NSView { 340 - let glassView = CollapsedPianoWaveformGlassEffectView() 341 - glassView.translatesAutoresizingMaskIntoConstraints = false 342 - glassView.cornerRadius = cornerRadius 343 - addSubview(glassView, positioned: .below, relativeTo: anchor) 344 - NSLayoutConstraint.activate([ 345 - glassView.leadingAnchor.constraint(equalTo: target.leadingAnchor), 346 - glassView.trailingAnchor.constraint(equalTo: target.trailingAnchor), 347 - glassView.topAnchor.constraint(equalTo: target.topAnchor), 348 - glassView.bottomAnchor.constraint(equalTo: target.bottomAnchor), 349 - ]) 350 - return glassView 351 - } 352 - 353 392 } 354 393 355 394 @available(macOS 26.0, *)
+74 -61
slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift
··· 14 14 } 15 15 16 16 private weak var menuBand: MenuBandController? 17 + private let outerStack = NSStackView() 17 18 private let contentStack = NSStackView() 19 + /// GM instrument chooser — same view the popover uses, embedded 20 + /// here on the left of the floating panel so the panel pairs 21 + /// with the popover (popover on the right, chooser on the left 22 + /// of the floating window). Doubles as an audio-reactive 23 + /// LED-grid visualizer. 24 + private let instrumentList = InstrumentListView() 18 25 private let waveformSection = NSView() 19 - private let waveformView = WaveformView() 20 26 private let waveformBezel = NSView() 21 - private let waveformClipView = NSView() 22 27 private let heldNotesStack = NSStackView() 23 28 private let heldNotesRow = NSView() 24 29 private let chordCandidatesStack = NSStackView() ··· 31 36 private let focusHintLabel = NSTextField(labelWithString: "") 32 37 private let layoutHintLabel = NSTextField(labelWithString: "") 33 38 private weak var paletteGlassView: NSView? 34 - private weak var waveformGlassView: NSView? 35 39 private weak var waveformSectionGlassView: NSView? 36 40 private weak var pianoGlassView: NSView? 37 41 private weak var keymapGlassView: NSView? ··· 51 55 private let heldNotesRowHeight: CGFloat = 26 52 56 private let chordCandidatesRowHeight: CGFloat = 30 53 57 private let chordCandidatesRowHorizontalInset: CGFloat = 6 54 - private var waveformHeightConstraint: NSLayoutConstraint? 55 58 private var isPresented = false 56 59 private var trackingArea: NSTrackingArea? 57 60 private static let panelCornerRadius: CGFloat = 18 ··· 70 73 super.init(frame: NSRect(origin: .zero, size: .zero)) 71 74 wantsLayer = true 72 75 73 - waveformView.menuBand = menuBand 74 - waveformView.translatesAutoresizingMaskIntoConstraints = false 75 - waveformView.setSurfaceStyle(.glassEmbedded) 76 76 contentStack.orientation = .vertical 77 77 contentStack.alignment = .centerX 78 78 contentStack.distribution = .fill 79 79 contentStack.spacing = gap 80 80 contentStack.translatesAutoresizingMaskIntoConstraints = false 81 + 82 + // Outer horizontal split: instrument chooser on the left, 83 + // existing piano + held-notes / chord-cards / qwerty stack 84 + // on the right. Aligned to top so a tall chooser doesn't 85 + // float in the vertical middle while the right column 86 + // sits at the top. 87 + outerStack.orientation = .horizontal 88 + outerStack.alignment = .top 89 + outerStack.distribution = .fill 90 + outerStack.spacing = gap 91 + outerStack.translatesAutoresizingMaskIntoConstraints = false 92 + 93 + // Wire the chooser the same way the popover does — commit 94 + // sets the program (auto-flips out of MIDI mode), hover 95 + // previews the program, music keys passthrough so qwerty 96 + // still plays while the chooser holds focus. 97 + instrumentList.menuBand = menuBand 98 + instrumentList.translatesAutoresizingMaskIntoConstraints = false 99 + instrumentList.onCommit = { [weak self] prog in 100 + guard let self = self, let m = self.menuBand else { return } 101 + if m.midiMode { m.toggleMIDIMode() } 102 + m.setMelodicProgram(UInt8(prog)) 103 + self.refresh() 104 + } 105 + instrumentList.onHover = { [weak self] prog in 106 + self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) 107 + self?.refresh() 108 + } 109 + instrumentList.onMusicKey = { [weak self] kc, isDown, isRepeat, flags in 110 + return self?.menuBand?.handleLocalKey( 111 + keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags 112 + ) ?? false 113 + } 81 114 waveformSection.wantsLayer = true 82 115 waveformSection.layer?.cornerRadius = Self.sectionCornerRadius 83 116 waveformSection.layer?.borderWidth = 0.8 ··· 92 125 waveformBezel.layer?.cornerCurve = .continuous 93 126 } 94 127 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 95 - waveformClipView.wantsLayer = true 96 - waveformClipView.translatesAutoresizingMaskIntoConstraints = false 97 - waveformClipView.layer?.cornerRadius = Self.waveformClipCornerRadius 98 - waveformClipView.layer?.masksToBounds = true 99 - if #available(macOS 10.15, *) { 100 - waveformClipView.layer?.cornerCurve = .continuous 101 - } 102 128 heldNotesStack.orientation = .horizontal 103 129 heldNotesStack.alignment = .centerY 104 130 heldNotesStack.spacing = 6 ··· 140 166 isRepeat: false, flags: []) 141 167 self.refresh() 142 168 } 143 - // Held-note pills and chord-suggestion cards overlay the metal 144 - // visualizer directly so the waveform doubles as the canvas 145 - // for the chord readout. Stack vertically: pills on top, cards 146 - // beneath, both centered. Waveform draws underneath, peeking 147 - // through the translucent card backgrounds. 148 - waveformBezel.addSubview(waveformClipView) 149 - waveformClipView.addSubview(waveformView) 169 + // Held-note pills and chord-suggestion cards live in the 170 + // bezel housing. The Metal visualizer that used to sit 171 + // underneath them was retired — the bezel is just the 172 + // pills+cards housing now. 150 173 waveformBezel.layer?.masksToBounds = false 151 174 heldNotesRow.wantsLayer = true 152 175 heldNotesRow.layer?.masksToBounds = false ··· 156 179 waveformBezel.addSubview(chordCandidatesRow) 157 180 waveformSection.addSubview(waveformBezel) 158 181 waveformSection.addSubview(instrumentTitleRow) 159 - addSubview(contentStack) 182 + addSubview(outerStack) 183 + outerStack.addArrangedSubview(instrumentList) 184 + outerStack.addArrangedSubview(contentStack) 160 185 contentStack.addArrangedSubview(waveformSection) 161 186 contentStack.addArrangedSubview(pianoView) 162 187 shortcutHintRow.addArrangedSubview(layoutHintLabel) ··· 176 201 installLiquidGlassBackgrounds() 177 202 178 203 let keyboardSize = self.keyboardSize() 179 - let waveformHeightConstraint = waveformView.heightAnchor.constraint( 180 - equalToConstant: waveformHeight(for: keyboardSize) 181 - ) 182 - self.waveformHeightConstraint = waveformHeightConstraint 204 + // Bezel is now sized purely by its content (held-notes pills 205 + // + chord cards) — the Metal canvas it used to host is gone. 183 206 let bezelInset: CGFloat = 5 184 207 let titleSpacers = instrumentTitleRow.arrangedSubviews 185 208 209 + // Total width is chooser (224) + gap + max(panel default, keyboard). 210 + // The right column gets at least expandedPanelWidth so the keyboard 211 + // and chord readout still feel roomy when the panel is paired with 212 + // the chooser on the left. 213 + let rightColumnWidth = max(keyboardSize.width + inset * 2, Self.expandedPanelWidth) 214 + let totalWidth = InstrumentListView.preferredWidth + gap + rightColumnWidth 215 + 186 216 NSLayoutConstraint.activate([ 187 - widthAnchor.constraint( 188 - equalToConstant: max(keyboardSize.width + inset * 2, Self.expandedPanelWidth) 189 - ), 217 + widthAnchor.constraint(equalToConstant: totalWidth), 190 218 191 - contentStack.topAnchor.constraint(equalTo: topAnchor, constant: inset), 192 - contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 193 - contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 194 - contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), 219 + outerStack.topAnchor.constraint(equalTo: topAnchor, constant: inset), 220 + outerStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 221 + outerStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 222 + outerStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), 223 + 224 + instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth), 225 + instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight), 226 + 227 + contentStack.widthAnchor.constraint(equalToConstant: rightColumnWidth), 195 228 196 229 waveformSection.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), 197 230 waveformSection.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), ··· 211 244 waveformBezel.topAnchor.constraint(equalTo: waveformSection.topAnchor, constant: bezelInset), 212 245 waveformBezel.leadingAnchor.constraint(equalTo: waveformSection.leadingAnchor, constant: bezelInset), 213 246 waveformBezel.trailingAnchor.constraint(equalTo: waveformSection.trailingAnchor, constant: -bezelInset), 214 - waveformClipView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 215 - waveformClipView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 216 - waveformClipView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 217 - waveformClipView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 218 - waveformView.leadingAnchor.constraint(equalTo: waveformClipView.leadingAnchor), 219 - waveformView.trailingAnchor.constraint(equalTo: waveformClipView.trailingAnchor), 220 - waveformView.topAnchor.constraint(equalTo: waveformClipView.topAnchor), 221 - waveformView.bottomAnchor.constraint(equalTo: waveformClipView.bottomAnchor), 222 - waveformHeightConstraint, 223 247 224 248 heldNotesRow.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 225 249 heldNotesRow.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), ··· 238 262 shortcutHintRow.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), 239 263 shortcutHintRow.heightAnchor.constraint(equalToConstant: hintHeight), 240 264 241 - qwertyView.centerXAnchor.constraint(equalTo: centerXAnchor), 265 + qwertyView.centerXAnchor.constraint(equalTo: contentStack.centerXAnchor), 242 266 qwertyView.widthAnchor.constraint( 243 267 equalToConstant: QwertyLayoutView.intrinsicSize.width * 1.4 244 268 ), ··· 364 388 365 389 func refresh() { 366 390 updateShortcutHint() 367 - let keyboardSize = keyboardSize() 368 - waveformHeightConstraint?.constant = waveformHeight(for: keyboardSize) 369 391 pianoView.refreshLayout() 370 392 layoutSubtreeIfNeeded() 371 393 applyAppearanceToVisualizer() 372 394 refreshHeldNotes() 373 395 updateInstrumentReadout() 374 396 applyWaveformTint() 375 - updateWaveformLiveState(isPresented: isPresented) 397 + if let m = menuBand { 398 + // Follow the *effective* program so the bee-vision center 399 + // tracks hover-drag previews + arrow-key stepping live. 400 + instrumentList.selectedProgram = m.effectiveMelodicProgram 401 + } 376 402 needsDisplay = true 377 403 pianoView.needsDisplay = true 378 404 } ··· 389 415 } 390 416 applyAppearanceToVisualizer() 391 417 applyWaveformTint() 392 - updateWaveformLiveState(isPresented: isPresented) 393 - } 394 - 395 - private func updateWaveformLiveState(isPresented: Bool) { 396 - waveformView.isLive = isPresented && !(menuBand?.midiMode ?? false) 397 - waveformView.alphaValue = (menuBand?.midiMode ?? false) ? 0.35 : 1.0 398 418 } 399 419 400 420 private func applyAppearanceToVisualizer() { 401 421 let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 402 - waveformView.setLightMode(!isDark) 403 422 if isDark { 404 423 waveformSection.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor 405 424 waveformBezel.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.04).cgColor ··· 411 430 412 431 private func applyWaveformTint() { 413 432 guard let menuBand else { return } 433 + // The visualizer is gone; this only retints the section 434 + // border so MIDI mode still reads as accent-colored chrome. 414 435 if menuBand.midiMode { 415 - waveformView.setDotMatrix(MenuBandPopoverViewController.midiDotPattern) 416 - waveformView.setBaseColor(.controlAccentColor) 417 436 waveformSection.layer?.borderColor = NSColor.controlAccentColor 418 437 .withAlphaComponent(0.24).cgColor 419 438 } else { 420 - waveformView.setDotMatrix(nil) 421 439 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 422 440 let familyColor = InstrumentListView.colorForProgram(safe) 423 - waveformView.setBaseColor(familyColor) 424 441 waveformSection.layer?.borderColor = familyColor 425 442 .withAlphaComponent(0.22).cgColor 426 443 } ··· 446 463 let piano = KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange) 447 464 return NSSize(width: piano.width * pianoScale, height: piano.height * pianoScale) 448 465 } 449 - } 450 - 451 - private func waveformHeight(for keyboard: NSSize) -> CGFloat { 452 - keyboard.height * 1.25 453 466 } 454 467 455 468 private func refreshHeldNotes() {
+39 -88
slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformViewController.swift
··· 11 11 private let containerView = NSView() 12 12 private let expandedView: ExpandedPianoWaveformView 13 13 private let collapsedView: CollapsedPianoWaveformView 14 - private let closeButton = NSButton() 15 - private let dockButton = NSButton() 16 14 private let expandCollapseButton = NSButton() 17 15 private var activeContentView: NSView? 18 16 private var presentationMode: PresentationMode = .expanded 19 17 private var isPresented = false 20 18 private var trackingArea: NSTrackingArea? 21 19 private var isMouseInsideView = false 22 - private weak var closeButtonGlassView: NSView? 23 - private weak var dockButtonGlassView: NSView? 24 20 private weak var expandCollapseButtonGlassView: NSView? 25 - private let closeButtonSize: CGFloat = 30 21 + private let closeButtonSize: CGFloat = 22 26 22 private let closeButtonCornerInset: CGFloat = 3 27 - private let gap: CGFloat = 8 28 - 29 - var onCloseRequested: (() -> Void)? 30 - 31 - var onDockRequested: (() -> Void)? 32 23 33 24 var onTogglePresentationMode: (() -> Void)? 34 25 ··· 72 63 override func loadView() { 73 64 view = containerView 74 65 containerView.wantsLayer = true 75 - configureOverlayButton( 76 - closeButton, 77 - symbolName: "xmark", 78 - toolTip: "Close", 79 - action: #selector(closeClicked(_:)) 80 - ) 81 - configureOverlayButton( 82 - dockButton, 83 - symbolName: "menubar.dock.rectangle", 84 - toolTip: "Dock Below Menubar Piano", 85 - action: #selector(dockClicked(_:)) 86 - ) 66 + // Only one overlay control — the expand/collapse toggle in the 67 + // top-right. Close and dock buttons were retired; the panel 68 + // is paired with the popover (closes when the popover closes) 69 + // and always docks against the popover's left edge. 87 70 configureOverlayButton( 88 71 expandCollapseButton, 89 - symbolName: "square.resize.down", 72 + symbolName: "arrow.up.left.and.arrow.down.right", 90 73 toolTip: "Collapse", 91 74 action: #selector(expandCollapseClicked(_:)) 92 75 ) 93 - containerView.addSubview(closeButton) 94 - containerView.addSubview(dockButton) 95 76 containerView.addSubview(expandCollapseButton) 96 77 installOverlayGlassBackgrounds() 97 78 NSLayoutConstraint.activate([ 98 - closeButton.topAnchor.constraint( 99 - equalTo: containerView.topAnchor, 100 - constant: PianoWaveformViewController.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 79 + expandCollapseButton.bottomAnchor.constraint( 80 + equalTo: containerView.bottomAnchor, 81 + constant: -(PianoWaveformViewController.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset) 101 82 ), 102 - closeButton.leadingAnchor.constraint( 83 + expandCollapseButton.leadingAnchor.constraint( 103 84 equalTo: containerView.leadingAnchor, 104 85 constant: PianoWaveformViewController.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset 105 86 ), 106 - closeButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 107 - closeButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 108 - 109 - expandCollapseButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 110 - expandCollapseButton.trailingAnchor.constraint( 111 - equalTo: containerView.trailingAnchor, 112 - constant: -(PianoWaveformViewController.panelCornerRadius - closeButtonSize / 2 + closeButtonCornerInset) 113 - ), 114 87 expandCollapseButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 115 88 expandCollapseButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 116 - 117 - dockButton.topAnchor.constraint(equalTo: closeButton.topAnchor), 118 - dockButton.trailingAnchor.constraint(equalTo: expandCollapseButton.leadingAnchor, constant: -gap), 119 - dockButton.widthAnchor.constraint(equalToConstant: closeButtonSize), 120 - dockButton.heightAnchor.constraint(equalToConstant: closeButtonSize), 121 89 ]) 122 90 installTrackingArea() 123 91 installDisplayedView() ··· 187 155 188 156 private func updatePresentationState() { 189 157 expandedView.setPresented(isPresented && presentationMode == .expanded) 190 - collapsedView.setLive(isPresented && presentationMode == .collapsed) 191 - let controlsHidden = false 192 - [closeButton, dockButton, expandCollapseButton, closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] 158 + // collapsed view no longer hosts a live visualizer; nothing to gate. 159 + [expandCollapseButton, expandCollapseButtonGlassView] 193 160 .compactMap { $0 } 194 - .forEach { $0.isHidden = controlsHidden } 161 + .forEach { $0.isHidden = false } 195 162 updateExpandCollapseButtonAppearance() 196 163 isMouseInsideView = isMouseInsideContainer() 197 164 setOverlayControlsVisible(isMouseInsideView, animated: false) ··· 204 171 toolTip: String, 205 172 action: Selector 206 173 ) { 207 - let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 174 + let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold) 208 175 button.translatesAutoresizingMaskIntoConstraints = false 209 176 button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 210 177 .withSymbolConfiguration(config) ··· 214 181 button.toolTip = toolTip 215 182 button.target = self 216 183 button.action = action 217 - button.alphaValue = 0 184 + // Always visible (no hover fade) — `setOverlayControlsVisible` 185 + // is now a no-op. Start at 1 so the button paints on first 186 + // appearance instead of waiting for the tracking-area enter. 187 + button.alphaValue = 1 218 188 button.wantsLayer = true 219 189 button.layer?.cornerRadius = closeButtonSize / 2 220 190 button.layer?.borderWidth = 1 ··· 225 195 226 196 private func installOverlayGlassBackgrounds() { 227 197 guard ExpandedPianoWaveformView.shouldUseLiquidGlass, #available(macOS 26.0, *) else { return } 228 - self.closeButtonGlassView = installGlassBackground(for: closeButton) 229 - self.dockButtonGlassView = installGlassBackground(for: dockButton) 230 198 self.expandCollapseButtonGlassView = installGlassBackground(for: expandCollapseButton) 231 199 } 232 200 ··· 246 214 } 247 215 248 216 private func setOverlayControlsVisible(_ isVisible: Bool, animated: Bool = true) { 249 - let alpha: CGFloat = isVisible ? 1.0 : 0.0 250 - let views = [closeButton, dockButton, expandCollapseButton, closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] 251 - .compactMap { $0 } 252 - if animated { 253 - NSAnimationContext.runAnimationGroup { context in 254 - context.duration = 0.12 255 - closeButton.animator().alphaValue = alpha 256 - dockButton.animator().alphaValue = alpha 257 - expandCollapseButton.animator().alphaValue = alpha 258 - closeButtonGlassView?.animator().alphaValue = alpha 259 - dockButtonGlassView?.animator().alphaValue = alpha 260 - expandCollapseButtonGlassView?.animator().alphaValue = alpha 261 - } 262 - } else { 263 - views.forEach { $0.alphaValue = alpha } 264 - } 217 + // Expand/collapse button is always visible now — keep the 218 + // method around so existing call sites still compile but 219 + // pin alpha to 1 regardless of hover state. `_ = isVisible` 220 + // / `_ = animated` swallow the parameters cleanly. 221 + _ = isVisible 222 + _ = animated 223 + expandCollapseButton.alphaValue = 1 224 + expandCollapseButtonGlassView?.alphaValue = 1 265 225 } 266 226 267 227 private func installTrackingArea() { ··· 305 265 let isDark = effectiveView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 306 266 let tintColor = expandedView.paletteTintColor 307 267 if ExpandedPianoWaveformView.shouldUseLiquidGlass, #available(macOS 26.0, *) { 308 - for view in [closeButtonGlassView, dockButtonGlassView, expandCollapseButtonGlassView] { 309 - (view as? NSGlassEffectView)?.style = .clear 310 - (view as? NSGlassEffectView)?.tintColor = tintColor.withAlphaComponent(0.34) 311 - } 312 - for button in [closeButton, dockButton, expandCollapseButton] { 313 - button.layer?.backgroundColor = NSColor.clear.cgColor 314 - button.layer?.borderColor = NSColor.clear.cgColor 315 - } 268 + (expandCollapseButtonGlassView as? NSGlassEffectView)?.style = .clear 269 + (expandCollapseButtonGlassView as? NSGlassEffectView)?.tintColor = tintColor.withAlphaComponent(0.34) 270 + expandCollapseButton.layer?.backgroundColor = NSColor.clear.cgColor 271 + expandCollapseButton.layer?.borderColor = NSColor.clear.cgColor 316 272 } else { 317 273 let border = NSColor.white.withAlphaComponent(0.28).cgColor 318 274 let background = NSColor.windowBackgroundColor.withAlphaComponent(isDark ? 0.18 : 0.22).cgColor 319 - for button in [closeButton, dockButton, expandCollapseButton] { 320 - button.layer?.backgroundColor = background 321 - button.layer?.borderColor = border 322 - } 275 + expandCollapseButton.layer?.backgroundColor = background 276 + expandCollapseButton.layer?.borderColor = border 323 277 } 324 278 } 325 279 326 280 private func updateExpandCollapseButtonAppearance() { 327 - let symbolName = presentationMode == .expanded ? "square.resize.down" : "square.resize.up" 281 + // Quick Look-style fullscreen toggle: diagonal corner arrows 282 + // pointing outward when collapsed (= "expand"), pointing 283 + // inward when expanded (= "exit fullscreen"). 284 + let symbolName = presentationMode == .expanded 285 + ? "arrow.down.right.and.arrow.up.left" 286 + : "arrow.up.left.and.arrow.down.right" 328 287 let toolTip = presentationMode == .expanded ? "Collapse" : "Expand floating piano" 329 - let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 288 + let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold) 330 289 expandCollapseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: toolTip)? 331 290 .withSymbolConfiguration(config) 332 291 expandCollapseButton.toolTip = toolTip 333 - } 334 - 335 - @objc private func closeClicked(_ sender: NSButton) { 336 - onCloseRequested?() 337 - } 338 - 339 - @objc private func dockClicked(_ sender: NSButton) { 340 - onDockRequested?() 341 292 } 342 293 343 294 @objc private func expandCollapseClicked(_ sender: NSButton) {
+104 -17
slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift
··· 102 102 103 103 var isDocked: Bool { collapsedCustomOrigin == nil } 104 104 105 + /// When the popover is on screen, its window frame is fed in here 106 + /// so the floating panel can right-align snug against the popover's 107 + /// left edge instead of anchoring under the menubar status item. 108 + /// Returning nil falls back to the status-item anchor. 109 + var popoverFrameProvider: (() -> NSRect?)? 110 + 105 111 init(menuBand: MenuBandController) { 106 112 self.menuBand = menuBand 107 113 self.pianoWaveformViewController = PianoWaveformViewController(menuBand: menuBand) ··· 117 123 ) 118 124 super.init() 119 125 120 - pianoWaveformViewController.onCloseRequested = { [weak self] in 121 - self?.disable(reason: .closeButton) 122 - } 123 - pianoWaveformViewController.onDockRequested = { [weak self] in 124 - self?.dockOnMenu() 125 - } 126 126 pianoWaveformViewController.onTogglePresentationMode = { [weak self] in 127 127 self?.togglePresentationMode() 128 128 } ··· 144 144 enableAndShowPreferred(restoringTo: previousApp) 145 145 } 146 146 147 + /// Force the expanded presentation regardless of saved preferred 148 + /// state — used when the popover opens so the GM chooser inside 149 + /// the expanded panel is what the user sees, not the collapsed 150 + /// strip (which has no chooser). 151 + func showExpandedForPopover(restoringTo previousApp: NSRunningApplication? = nil) { 152 + setEnabled(true) 153 + cancelPendingHide() 154 + transitionToExpanded() 155 + showExpanded(restoringTo: previousApp) 156 + } 157 + 158 + /// Force the collapsed presentation regardless of saved preferred 159 + /// state. Used when pairing with the popover — the popover always 160 + /// brings up the small panel; the expanded view is only reachable 161 + /// by user action (the expand button on the small panel). 162 + /// The user's saved preferred state is left untouched so a 163 + /// standalone open later still honors it. 164 + func showCollapsedForPopover() { 165 + setEnabled(true) 166 + cancelPendingHide() 167 + if panel == nil { buildPanel() } 168 + if presentationState == .expanded { 169 + dismissExpanded(reason: .programmatic) 170 + } 171 + presentationState = .collapsed 172 + showCollapsedIfNeeded() 173 + } 174 + 147 175 func dismiss(reason: DismissReason = .programmatic) { 148 - guard presentationState == .expanded else { return } 149 - dismissExpanded(reason: reason) 176 + // Closes whichever panel is currently presented. The popover 177 + // pairs the panel one-to-one, so when the popover dismisses 178 + // we want both expanded and collapsed forms to go away. 179 + switch presentationState { 180 + case .expanded: 181 + dismissExpanded(reason: reason) 182 + case .collapsed: 183 + dismissCollapsedPanel() 184 + } 150 185 } 151 186 152 187 func refresh() { ··· 230 265 showIfNeeded() 231 266 pianoWaveformViewController.refresh() 232 267 focusCollapsedPaletteIfNeeded() 233 - if menuBand.litNotes.isEmpty { 268 + // Only auto-hide if we're standalone; when paired with the 269 + // popover (popoverFrameProvider returns a frame), the panel 270 + // must stay until the popover closes. 271 + let pairedWithPopover = (popoverFrameProvider?() != nil) 272 + if menuBand.litNotes.isEmpty && !pairedWithPopover { 234 273 scheduleHide() 274 + } else { 275 + cancelPendingHide() 235 276 } 236 277 } 237 278 ··· 248 289 } 249 290 250 291 private func buildPanel() { 292 + // Singleton guard — every show/refresh path already passes 293 + // `if panel == nil { buildPanel() }`, but be explicit here 294 + // so a future caller can't accidentally rebuild a fresh 295 + // panel and leave the prior one orphaned on screen. 296 + guard panel == nil else { return } 251 297 let panel = PianoWaveformPanel( 252 298 contentRect: NSRect(origin: .zero, size: pianoWaveformViewController.preferredContentSize), 253 299 styleMask: [.titled, .closable, .fullSizeContentView], ··· 263 309 panel.collectionBehavior = [.transient] 264 310 panel.hidesOnDeactivate = false 265 311 panel.canHide = false 266 - panel.isMovableByWindowBackground = true 312 + // Drag-by-background is off — the panel always pairs with the 313 + // popover (snug-left), so a draggable body just lets clicks 314 + // on the chooser / held-notes / button area accidentally 315 + // move the window. 316 + panel.isMovableByWindowBackground = false 267 317 panel.acceptsMouseMovedEvents = true 268 318 panel.titleVisibility = .hidden 269 319 panel.titlebarAppearsTransparent = true ··· 343 393 344 394 private func dismissCollapsedPanel() { 345 395 cancelPendingHide() 346 - guard menuBand.litNotes.isEmpty else { 347 - if preferredPresentationState == .collapsed, isEnabled, !isCollapsedPresentationSuppressed { 348 - showCollapsedIfNeeded() 349 - } 350 - return 351 - } 396 + // Hard close — popover dismiss is the canonical "go away" 397 + // signal for the paired panel. Earlier this guarded on held 398 + // notes (kept the panel open while you were still pressing 399 + // a chooser cell), but that left a stale panel on screen 400 + // when the popover came back, doubling instances. 401 + menuBand.releaseAllHeldNotes() 352 402 pianoWaveformViewController.setPresented(false) 353 403 panel?.ignoresMouseEvents = false 354 404 panel?.orderOut(nil) ··· 455 505 } 456 506 457 507 private func expandedFrame(size: NSSize, fallbackOrigin: NSPoint?) -> NSRect { 508 + // Popover-snug positioning wins over the saved drag origin so 509 + // the expanded panel pairs cleanly with the popover when both 510 + // are on screen. 511 + if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { 512 + return NSRect( 513 + x: popoverRect.minX - size.width, 514 + y: popoverRect.maxY - size.height, 515 + width: size.width, 516 + height: size.height 517 + ) 518 + } 458 519 let origin = fallbackOrigin ?? savedExpandedOrigin 459 520 return clampedFrame( 460 521 origin: origin ?? centeredOrigin(for: size), ··· 464 525 } 465 526 466 527 private func collapsedFrame(size: NSSize) -> NSRect { 528 + // Popover-snug positioning always wins over the user's 529 + // dragged-to position. The floating panel pairs with the 530 + // popover; honoring a stale custom origin while the popover 531 + // is up would scatter the two surfaces. 532 + if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { 533 + return NSRect( 534 + x: popoverRect.minX - size.width, 535 + y: popoverRect.maxY - size.height, 536 + width: size.width, 537 + height: size.height 538 + ) 539 + } 467 540 guard let anchoredFrame = anchoredCollapsedFrame(size: size) else { 468 541 return clampedFrame( 469 542 origin: collapsedCustomOrigin ?? centeredOrigin(for: size), ··· 480 553 } 481 554 482 555 private func anchoredCollapsedFrame(size: NSSize) -> NSRect? { 556 + // Snug-left-of-popover takes precedence whenever the popover is 557 + // on screen — the floating panel's right edge sits flush 558 + // against the popover's left edge, tops aligned, so the two 559 + // surfaces read as one continuous strip with the popover on 560 + // the right and the floating piano on the left. 561 + if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty { 562 + return NSRect( 563 + x: popoverRect.minX - size.width, 564 + y: popoverRect.maxY - size.height, 565 + width: size.width, 566 + height: size.height 567 + ) 568 + } 569 + 483 570 guard let button = statusItemButton, 484 571 let buttonWindow = button.window else { return nil } 485 572 ··· 538 625 isPositioningPanel = false 539 626 } 540 627 541 - private func cancelPendingHide() { 628 + func cancelPendingHide() { 542 629 hideWorkItem?.cancel() 543 630 hideWorkItem = nil 544 631 }
+8
slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowSupport.swift
··· 49 49 final class PianoWaveformPanel: NSPanel { 50 50 override var canBecomeKey: Bool { true } 51 51 override var canBecomeMain: Bool { false } 52 + /// Keep the glass + control chrome painted in "active" state at 53 + /// all times. The popover steals key focus when it opens, which 54 + /// would otherwise shift NSGlassEffectView into a desaturated/ 55 + /// reduced-blur "inactive" appearance — visually the panel 56 + /// looked thicker the moment the popover went up. Pinning 57 + /// `isMainWindow` to true keeps the glass uniform regardless of 58 + /// focus state. 59 + override var isMainWindow: Bool { true } 52 60 }
+81 -22
slab/menuband/Sources/MenuBand/QwertyLayoutView.swift
··· 37 37 /// up on mouseUp. 38 38 private var heldByPointer: UInt16? 39 39 40 - static let intrinsicSize = NSSize(width: 180, height: 46) 40 + static let intrinsicSize = NSSize(width: 180, height: 60) 41 41 /// Multiplier applied to all keycap dimensions + label font size. 42 42 /// Default 1.0 = popover layout (compact). The floating play 43 43 /// palette sets a larger value so the keymap fills the overlay. ··· 65 65 override var isFlipped: Bool { false } 66 66 override var mouseDownCanMoveWindow: Bool { false } 67 67 68 - /// Three-row macOS QWERTY layout — keys identified by `kVK_*` 68 + /// Cap descriptor — `width` is in standard-key units (1.0 = a 69 + /// regular letter cap; shift = 1.5, space = 5.0). 70 + private struct Cap { 71 + let kc: UInt16 72 + let label: String 73 + let width: CGFloat 74 + init(_ kc: UInt16, _ label: String, width: CGFloat = 1.0) { 75 + self.kc = kc 76 + self.label = label 77 + self.width = width 78 + } 79 + } 80 + 81 + /// Four-row macOS QWERTY layout — keys identified by `kVK_*` 69 82 /// codes, mirroring `MenuBandLayout.panByKeyCode` so the visual 70 - /// position reflects the keypad-driven pan. 71 - private static let rows: [[(kc: UInt16, label: String)]] = [ 83 + /// position reflects the keypad-driven pan. Row 2 includes the 84 + /// notepat octave keys (`,` and `.`); row 3 holds shift / space 85 + /// / shift so the linger-mode and metronome-toggle keys are 86 + /// visible alongside the note caps. 87 + private static let rows: [[Cap]] = [ 72 88 // Top row: q w e r t y u i o p ] 73 - [(12, "q"), (13, "w"), (14, "e"), (15, "r"), (17, "t"), 74 - (16, "y"), (32, "u"), (34, "i"), (31, "o"), (35, "p"), (30, "]")], 89 + [Cap(12, "q"), Cap(13, "w"), Cap(14, "e"), Cap(15, "r"), Cap(17, "t"), 90 + Cap(16, "y"), Cap(32, "u"), Cap(34, "i"), Cap(31, "o"), Cap(35, "p"), Cap(30, "]")], 75 91 // Home row: a s d f g h j k l ; ' 76 - [(0, "a"), (1, "s"), (2, "d"), (3, "f"), (5, "g"), 77 - (4, "h"), (38, "j"), (40, "k"), (37, "l"), (41, ";"), (39, "'")], 78 - // Bottom row: z x c v b n m 79 - [(6, "z"), (7, "x"), (8, "c"), (9, "v"), (11, "b"), 80 - (45, "n"), (46, "m")], 92 + [Cap(0, "a"), Cap(1, "s"), Cap(2, "d"), Cap(3, "f"), Cap(5, "g"), 93 + Cap(4, "h"), Cap(38, "j"), Cap(40, "k"), Cap(37, "l"), Cap(41, ";"), Cap(39, "'")], 94 + // Bottom row: z x c v b n m , . 95 + [Cap(6, "z"), Cap(7, "x"), Cap(8, "c"), Cap(9, "v"), Cap(11, "b"), 96 + Cap(45, "n"), Cap(46, "m"), Cap(43, ","), Cap(47, ".")], 97 + // Modifier / space row: shift space shift — no glyph 98 + // on the space bar; the wide blank cap reads as space. 99 + [Cap(56, "⇧", width: 1.6), Cap(49, "", width: 5.5), Cap(60, "⇧", width: 1.6)], 81 100 ] 82 - private static let rowOffsets: [CGFloat] = [0, 0.5, 1.0] 101 + private static let rowOffsets: [CGFloat] = [0, 0.5, 1.0, 0.0] 83 102 private static let keySize: CGFloat = 14 84 103 private static let keyGap: CGFloat = 1 85 104 private static let cornerRadius: CGFloat = 2.5 ··· 104 123 /// Skips unmapped Ableton keys (matching the draw-time hide rule) 105 124 /// so a click on empty space doesn't accidentally trigger a key 106 125 /// that isn't there. 107 - private func forEachVisibleCap(_ body: (_ cap: (kc: UInt16, label: String), _ rect: NSRect) -> Void) { 126 + private func forEachVisibleCap(_ body: (_ cap: Cap, _ rect: NSRect) -> Void) { 108 127 let r = bounds 109 128 let kSize = scaledKeySize 110 129 let kGap = scaledKeyGap 111 130 let totalRows = CGFloat(Self.rows.count) 112 131 let rowSpan = totalRows * kSize + (totalRows - 1) * kGap 113 132 let topY = r.midY + rowSpan / 2 - kSize 114 - let homeRowSpan = CGFloat(Self.rows[1].count) * kSize + 115 - CGFloat(Self.rows[1].count - 1) * kGap + 133 + let octaves = MenuBandLayout.octaveKeyCodes(for: keymap) 134 + // Pre-compute the home row's total span as the centering 135 + // reference so all rows share the same horizontal anchor. 136 + let homeRow = Self.rows[1] 137 + let homeWidth = homeRow.reduce(0.0) { $0 + $1.width * kSize } + 138 + CGFloat(homeRow.count - 1) * kGap + 116 139 Self.rowOffsets[1] * kSize 117 - let leftX = r.midX - homeRowSpan / 2 118 - let octaves = MenuBandLayout.octaveKeyCodes(for: keymap) 140 + let centerX = r.midX 119 141 for (rIdx, row) in Self.rows.enumerated() { 120 142 let y = topY - CGFloat(rIdx) * (kSize + kGap) 121 143 let xOffset = Self.rowOffsets[rIdx] * kSize 122 - for (cIdx, cap) in row.enumerated() { 144 + // Sum the row's visible widths so we can right-truncate 145 + // it (Ableton hides unmapped letters) and still center 146 + // the row's caps relative to the home row's left edge. 147 + // For the modifier row (4) we instead center on the 148 + // panel midline so shift / space / shift sit centered. 149 + var rowWidth: CGFloat = 0 150 + var visibleCount = 0 151 + for cap in row { 123 152 let st = semitone(cap.kc) 124 - let isOctaveKey = (cap.kc == octaves.down || cap.kc == octaves.up) 125 - if keymap == .ableton && st == nil && !isOctaveKey { continue } 126 - let x = leftX + xOffset + CGFloat(cIdx) * (kSize + kGap) 127 - let kr = NSRect(x: x, y: y, width: kSize, height: kSize) 153 + let isOct = (cap.kc == octaves.down || cap.kc == octaves.up) 154 + if keymap == .ableton && st == nil && !isOct 155 + && !Self.isModifierKey(cap.kc) { continue } 156 + rowWidth += cap.width * kSize 157 + visibleCount += 1 158 + } 159 + if visibleCount > 0 { 160 + rowWidth += CGFloat(visibleCount - 1) * kGap 161 + } 162 + let leftX: CGFloat 163 + if rIdx == 3 { 164 + // Modifier row centers on the panel midline so the 165 + // shift / space / shift cluster reads as the bottom 166 + // of a real keyboard. 167 + leftX = centerX - rowWidth / 2 168 + } else { 169 + leftX = centerX - homeWidth / 2 + xOffset 170 + } 171 + var cursorX = leftX 172 + for cap in row { 173 + let st = semitone(cap.kc) 174 + let isOct = (cap.kc == octaves.down || cap.kc == octaves.up) 175 + if keymap == .ableton && st == nil && !isOct 176 + && !Self.isModifierKey(cap.kc) { continue } 177 + let w = cap.width * kSize 178 + let kr = NSRect(x: cursorX, y: y, width: w, height: kSize) 128 179 body(cap, kr) 180 + cursorX += w + kGap 129 181 } 130 182 } 183 + } 184 + 185 + /// Shift / space — non-note caps that should always render even 186 + /// in keymap variants that hide unmapped letters. 187 + private static func isModifierKey(_ kc: UInt16) -> Bool { 188 + kc == 49 || kc == 56 || kc == 60 131 189 } 132 190 133 191 /// Hit-test a point in view coordinates against the visible caps. ··· 258 316 y: rect.midY - size.height / 2 - 0.5)) 259 317 } 260 318 } 319 + // rebuild marker 1777878530
-112
slab/menuband/Sources/MenuBand/WaveformShaders.metal
··· 1 - #include <metal_stdlib> 2 - using namespace metal; 3 - 4 - struct Uniforms { 5 - float viewW; 6 - float viewH; 7 - float barW; 8 - float stride; 9 - float minHeight; 10 - float4 color; 11 - float dotMatrix; 12 - float isLight; 13 - }; 14 - 15 - struct VertexOut { 16 - float4 position [[position]]; 17 - float level; 18 - float mask; 19 - }; 20 - 21 - // Two triangles spanning the unit square (CCW), shared across all bar 22 - // instances. The vertex shader scales each instance into a bar rect 23 - // and converts pixel space to clip space. 24 - constant float2 unitQuad[6] = { 25 - float2(0, 0), float2(1, 0), float2(0, 1), 26 - float2(0, 1), float2(1, 0), float2(1, 1) 27 - }; 28 - 29 - // Segmented LED-meter look. Each bar is rendered as a full-height 30 - // rect; the fragment shader carves it into stacked segments by 31 - // level. Tweak NSEG / SEG_GAP to taste — old stereo VU meters 32 - // typically had 10–14 segments with a small dim row above the lit 33 - // ones to hint at the headroom. 34 - constant float NSEG = 10.0; 35 - constant float SEG_GAP = 0.32; 36 - constant float UNLIT_ALPHA = 0.12; 37 - // Hot-zone: above this fraction of the bar height, the lit color 38 - // brightens toward white so the top of the meter still reads as 39 - // "peaking" even when the instrument's base color is e.g. a deep 40 - // navy bass. Linear ramp from HOT_AT → top. 41 - constant float HOT_AT = 0.70; 42 - 43 - vertex VertexOut bar_vertex(uint vid [[vertex_id]], 44 - uint iid [[instance_id]], 45 - constant Uniforms &u [[buffer(0)]], 46 - constant float *levels [[buffer(1)]], 47 - constant float *masks [[buffer(2)]]) 48 - { 49 - float2 local = unitQuad[vid]; 50 - float barX = float(iid) * u.stride; 51 - // Full-height geometry — fragment shader masks per-segment. 52 - float h = u.viewH; 53 - float px = barX + local.x * u.barW; 54 - float py = local.y * h; 55 - // Pixel space → clip space ([-1, 1] on both axes). 56 - float clipX = (px / u.viewW) * 2.0 - 1.0; 57 - float clipY = (py / u.viewH) * 2.0 - 1.0; 58 - VertexOut out; 59 - out.position = float4(clipX, clipY, 0, 1); 60 - out.level = levels[iid]; 61 - out.mask = masks[iid]; 62 - return out; 63 - } 64 - 65 - fragment float4 bar_fragment(VertexOut in [[stage_in]], 66 - constant Uniforms &u [[buffer(0)]]) 67 - { 68 - // [[position]] gives fragment pixel-space y with origin at TOP. 69 - // Flip so y01=0 is bottom of the bar (where amplitude starts). 70 - float y01 = 1.0 - (in.position.y / u.viewH); 71 - // Inter-segment gap: top fraction of each segment cell stays 72 - // transparent so the bar reads as a stack rather than a solid. 73 - float segPos = fract(y01 * NSEG); 74 - if (segPos > (1.0 - SEG_GAP)) { 75 - discard_fragment(); 76 - } 77 - // Bar color = the instrument's chosen base hue, passed in via 78 - // u.color. In dark mode the top brightens toward white (LED 79 - // glow); in light mode it darkens toward black (ink saturation 80 - // at peak) — both read as "this bar is hotter at the top" 81 - // against their respective substrates. 82 - float3 base = u.color.rgb; 83 - float hot = max(0.0, (y01 - HOT_AT) / (1.0 - HOT_AT)); 84 - float3 hotTarget = (u.isLight > 0.5) ? float3(0.0, 0.0, 0.0) : float3(1.0, 1.0, 1.0); 85 - float3 tier = mix(base, hotTarget, hot * 0.65); 86 - // Per-segment glow: brighter at the center of each LED cell, 87 - // falling off toward the gap edges. Reads as a soft bloom on 88 - // each lit segment without a real blur pass. 89 - float visibleSegPos = segPos / (1.0 - SEG_GAP); 90 - float segCenterDist = abs(visibleSegPos - 0.5) * 2.0; 91 - float bloom = pow(1.0 - segCenterDist, 1.6); 92 - // Lit = below the level (live VU) OR the corresponding bit 93 - // is set in this bar's dot-matrix mask. The mask path lets 94 - // us spell static text out of the LED segments — used in 95 - // MIDI mode to render "MIDI". 96 - uint segIndex = uint(floor(y01 * NSEG)); 97 - uint mask = uint(in.mask); 98 - bool maskLit = (u.dotMatrix > 0.5) && (((mask >> segIndex) & 1u) != 0u); 99 - bool levelLit = y01 < in.level; 100 - bool lit = maskLit || levelLit; 101 - // Bloom direction also flips with the substrate so the 102 - // per-segment glow reinforces "hotter" instead of fighting it. 103 - float bloomSign = (u.isLight > 0.5) ? -1.0 : 1.0; 104 - float3 color = lit ? (tier + bloomSign * bloom * 0.40) : tier; 105 - // In light mode unlit segments fade toward the substrate (warm 106 - // off-white) instead of toward black — without this the 107 - // "off" rows show as faint colored dots, which reads as a row 108 - // of always-on LEDs rather than empty headroom. 109 - float unlitAlpha = (u.isLight > 0.5) ? 0.20 : UNLIT_ALPHA; 110 - float a = lit ? u.color.a : (u.color.a * unlitAlpha); 111 - return float4(clamp(color, float3(0.0), float3(1.0)), a); 112 - }
-482
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 1 - import AppKit 2 - import Metal 3 - import MetalKit 4 - import simd 5 - 6 - /// Bottom-anchored audio bars rendered with Metal. The previous CAShapeLayer 7 - /// path swap was already vsync-driven, but Metal lets the bar geometry live 8 - /// in a vertex shader (instanced quads) instead of rebuilding a CGPath on 9 - /// the main thread every frame, and gives us a clean substrate for richer 10 - /// visualizers later (FFT, particles, gradients) without re-architecting. 11 - /// 12 - /// Sixteen instanced quads, accent-colored fill, opaque dark background, 13 - /// driven by MTKView's internal CVDisplayLink at the screen's native 14 - /// refresh. Hidden when MIDI mode is on. 15 - final class WaveformView: MTKView { 16 - enum SurfaceStyle { 17 - case standard 18 - case glassEmbedded 19 - } 20 - 21 - weak var menuBand: MenuBandController? 22 - 23 - private static let barCount = 16 24 - private static let barGapPts: Float = 3 // points; multiplied by contentsScale at draw time 25 - private static let snapshotSize = 256 26 - 27 - private var samples = [Float](repeating: 0, count: snapshotSize) 28 - private var smoothedPeak: Float = 0.05 29 - private var levels = [Float](repeating: 0, count: barCount) 30 - 31 - private var commandQueue: MTLCommandQueue? 32 - private var pipelineState: MTLRenderPipelineState? 33 - private var uniforms = BarUniforms() 34 - /// Per-bar smoothed level — gives the bars visual "ballistics" so they 35 - /// don't pop instantly between frames. Decay slower than rise so attack 36 - /// transients punch but releases trail off naturally. 37 - private var displayLevels = [Float](repeating: 0, count: barCount) 38 - /// Explicit display link. NSPopover hosts content in an NSPanel whose 39 - /// runloop coalesces setNeedsDisplay-triggered redraws, and MTKView's 40 - /// internal CVDisplayLink doesn't fire reliably when the panel isn't 41 - /// `main`. We drive draws ourselves and call `display()` so the redraw 42 - /// is synchronous instead of deferred. 43 - private var displayLink: CVDisplayLink? 44 - private let pendingDisplayLock = NSLock() 45 - private var pendingDisplay = false 46 - /// True only after this view has successfully enabled synth waveform 47 - /// capture for its own live session. `stopLink()` can be reached from 48 - /// multiple lifecycle paths, including ones where no link was started, so 49 - /// we need a per-view lease bit to avoid over-releasing the controller's 50 - /// shared capture refcount. 51 - private var hasCaptureLease = false 52 - private var surfaceStyle: SurfaceStyle = .standard 53 - 54 - var isLive: Bool = false { 55 - didSet { 56 - guard isLive != oldValue else { return } 57 - if isLive { 58 - stopDotMatrix() 59 - startLink() 60 - } else { 61 - stopLink() 62 - for i in 0..<levels.count { levels[i] = 0 } 63 - for i in 0..<displayLevels.count { displayLevels[i] = 0 } 64 - requestDisplay() // one final paint to clear bars 65 - } 66 - } 67 - } 68 - 69 - /// When true, the bars stop running the live VU and instead 70 - /// render the static `dotMasks` pattern (used to spell "MIDI" 71 - /// while in MIDI mode). Reset masks + uniform when switching 72 - /// out so live mode resumes cleanly. 73 - private var dotMatrixActive: Bool = false 74 - /// Per-bar 32-bit mask. Bit i = whether segment i (0=bottom, 75 - /// 9=top) is lit. Sized to `barCount`. 76 - private var dotMasks: [Float] = Array(repeating: 0, count: 32) 77 - 78 - /// Render a static dot-matrix pattern instead of the live VU 79 - /// bars. Pass nil to clear and return to live mode. 80 - func setDotMatrix(_ mask: [UInt32]?) { 81 - if let mask = mask { 82 - // Encode UInt32 → Float for transport (Float can hold up 83 - // to 2^24 exactly, and our masks are 10 bits). 84 - for i in 0..<dotMasks.count { 85 - dotMasks[i] = Float(i < mask.count ? mask[i] : 0) 86 - } 87 - uniforms.dotMatrix = 1 88 - dotMatrixActive = true 89 - // Static frame — nudge a single redraw so the pattern 90 - // appears even when the live link is off. 91 - requestDisplay() 92 - } else { 93 - stopDotMatrix() 94 - } 95 - } 96 - 97 - private func stopDotMatrix() { 98 - if dotMatrixActive { 99 - for i in 0..<dotMasks.count { dotMasks[i] = 0 } 100 - uniforms.dotMatrix = 0 101 - dotMatrixActive = false 102 - requestDisplay() 103 - } 104 - } 105 - 106 - private func startLink() { 107 - stopLink() 108 - guard window != nil else { return } 109 - var link: CVDisplayLink? 110 - CVDisplayLinkCreateWithActiveCGDisplays(&link) 111 - guard let link = link else { return } 112 - let opaque = Unmanaged.passUnretained(self).toOpaque() 113 - CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx in 114 - guard let ctx = ctx else { return kCVReturnSuccess } 115 - let view = Unmanaged<WaveformView>.fromOpaque(ctx).takeUnretainedValue() 116 - // display() is main-thread-only; hop over and draw synchronously 117 - // so the redraw can't be coalesced by the popover's runloop. 118 - // Coalesce callbacks while the main queue is busy; otherwise a 119 - // slow frame can build a backlog of stale draw requests. 120 - guard view.markDisplayPending() else { return kCVReturnSuccess } 121 - DispatchQueue.main.async { 122 - view.requestDisplay() 123 - view.clearDisplayPending() 124 - } 125 - return kCVReturnSuccess 126 - }, opaque) 127 - let status = CVDisplayLinkStart(link) 128 - guard status == kCVReturnSuccess else { return } 129 - menuBand?.setWaveformCaptureEnabled(true) 130 - hasCaptureLease = true 131 - displayLink = link 132 - } 133 - 134 - private func stopLink() { 135 - if let link = displayLink { 136 - CVDisplayLinkStop(link) 137 - displayLink = nil 138 - } 139 - if hasCaptureLease { 140 - menuBand?.setWaveformCaptureEnabled(false) 141 - hasCaptureLease = false 142 - } 143 - clearDisplayPending() 144 - } 145 - 146 - private func markDisplayPending() -> Bool { 147 - pendingDisplayLock.lock() 148 - defer { pendingDisplayLock.unlock() } 149 - if pendingDisplay { return false } 150 - pendingDisplay = true 151 - return true 152 - } 153 - 154 - private func clearDisplayPending() { 155 - pendingDisplayLock.lock() 156 - pendingDisplay = false 157 - pendingDisplayLock.unlock() 158 - } 159 - 160 - private var canDrawSurface: Bool { 161 - guard window?.isVisible == true, 162 - !isHiddenOrHasHiddenAncestor, 163 - bounds.width > 0, 164 - bounds.height > 0 else { 165 - return false 166 - } 167 - return true 168 - } 169 - 170 - private func requestDisplay() { 171 - guard Thread.isMainThread else { 172 - DispatchQueue.main.async { [weak self] in 173 - self?.requestDisplay() 174 - } 175 - return 176 - } 177 - guard canDrawSurface else { return } 178 - display() 179 - } 180 - 181 - deinit { stopLink() } 182 - 183 - override func viewDidMoveToWindow() { 184 - super.viewDidMoveToWindow() 185 - if window == nil { 186 - stopLink() 187 - } else if isLive && displayLink == nil { 188 - startLink() 189 - } 190 - } 191 - 192 - init() { 193 - guard let device = MTLCreateSystemDefaultDevice() else { 194 - fatalError("MenuBand: no Metal device — every Mac since 2012 has one, " 195 - + "so this should not happen on macOS 11+") 196 - } 197 - super.init(frame: .zero, device: device) 198 - wantsLayer = true 199 - // Note: not using layer.cornerRadius — CAMetalLayer with 200 - // framebufferOnly = true can't be reliably clipped by a corner 201 - // mask. Square corners for the visualizer are fine; if we want 202 - // them rounded later, wrap in a clipping container view. 203 - 204 - // Opaque background — the previous 0.92 alpha was barely a tint and 205 - // making the layer translucent costs us framebufferOnly + complicates 206 - // the blend setup. Solid black reads identical at the popover scale. 207 - clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1.0) 208 - framebufferOnly = true 209 - (layer as? CAMetalLayer)?.isOpaque = true 210 - 211 - // We drive draws via our own CVDisplayLink + display() so MTKView's 212 - // internal frame timing doesn't fight the popover's runloop mode. 213 - // `enableSetNeedsDisplay = true` puts MTKView in dirty-rect mode; 214 - // `isPaused = false` so display() actually paints when called. 215 - enableSetNeedsDisplay = true 216 - 217 - // Disable vsync on the Metal layer so frames are presented immediately 218 - // rather than waiting for the next display refresh. Without this, our 219 - // CVDisplayLink-driven draw calls can stall for up to one refresh 220 - // interval because the layer tries to sync presentation to the display, 221 - // creating visible latency between audio input and the rendered 222 - // waveform — and adding noticeable delay when the popover first 223 - // appears. Patch contributed by Esteban Uribe. 224 - if let metalLayer = layer as? CAMetalLayer { 225 - metalLayer.displaySyncEnabled = false 226 - } 227 - 228 - isPaused = false 229 - preferredFramesPerSecond = 0 230 - 231 - commandQueue = device.makeCommandQueue() 232 - buildPipeline(device: device) 233 - delegate = self 234 - applyAccentColor() 235 - } 236 - 237 - required init(coder: NSCoder) { 238 - fatalError("WaveformView is code-only; init(coder:) is not supported") 239 - } 240 - 241 - override var isOpaque: Bool { true } 242 - override var mouseDownCanMoveWindow: Bool { true } 243 - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 244 - 245 - private func buildPipeline(device: MTLDevice) { 246 - do { 247 - // SwiftPM's executableTarget doesn't compile bundled .metal 248 - // files into a default.metallib (the auto-compile path is 249 - // an Xcode build-phase, not an SPM rule). We declare the 250 - // .metal file as a `.process()` resource so SPM copies the 251 - // SOURCE into the module bundle, then compile it here at 252 - // first-pipeline-build via `makeLibrary(source:)`. Cost is 253 - // ~10ms once on launch — invisible next to the popover 254 - // animation. Without this the visualizer ships solid black: 255 - // makeDefaultLibrary throws "no default library was found". 256 - let library: MTLLibrary 257 - if let url = Bundle.module.url(forResource: "WaveformShaders", withExtension: "metal"), 258 - let source = try? String(contentsOf: url, encoding: .utf8) { 259 - library = try device.makeLibrary(source: source, options: nil) 260 - } else { 261 - library = try device.makeDefaultLibrary(bundle: .module) 262 - } 263 - guard let vfn = library.makeFunction(name: "bar_vertex"), 264 - let ffn = library.makeFunction(name: "bar_fragment") else { 265 - NSLog("MenuBand: visualizer shader functions missing") 266 - return 267 - } 268 - let pd = MTLRenderPipelineDescriptor() 269 - pd.vertexFunction = vfn 270 - pd.fragmentFunction = ffn 271 - pd.colorAttachments[0].pixelFormat = colorPixelFormat 272 - // Alpha blending — fragment shader emits alpha < 1 for unlit 273 - // segments so the empty rows of the LED meter dim out instead 274 - // of staying full-color. Without blending, alpha is ignored 275 - // and every "off" segment renders at full intensity. 276 - pd.colorAttachments[0].isBlendingEnabled = true 277 - pd.colorAttachments[0].rgbBlendOperation = .add 278 - pd.colorAttachments[0].alphaBlendOperation = .add 279 - pd.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha 280 - pd.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha 281 - pd.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha 282 - pd.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha 283 - pipelineState = try device.makeRenderPipelineState(descriptor: pd) 284 - } catch { 285 - NSLog("MenuBand: visualizer Metal pipeline failed: \(error)") 286 - } 287 - } 288 - 289 - override func viewDidChangeEffectiveAppearance() { 290 - super.viewDidChangeEffectiveAppearance() 291 - applyAccentColor() 292 - } 293 - 294 - private func applyAccentColor() { 295 - let c = NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? NSColor.systemTeal 296 - uniforms.color = SIMD4<Float>(Float(c.redComponent), 297 - Float(c.greenComponent), 298 - Float(c.blueComponent), 299 - 1.0) 300 - } 301 - 302 - /// Override the visualizer's base color — used so the LED meter 303 - /// matches the chosen GM instrument. Top of the bar still brightens 304 - /// toward white in the shader (hot-zone VU feel), so passing in a 305 - /// dim mid-tone still reads with peak indication. Pass `nil` to 306 - /// revert to the system accent color. 307 - func setBaseColor(_ color: NSColor?) { 308 - guard let color = color, 309 - let c = color.usingColorSpace(.sRGB) else { 310 - applyAccentColor() 311 - return 312 - } 313 - uniforms.color = SIMD4<Float>(Float(c.redComponent), 314 - Float(c.greenComponent), 315 - Float(c.blueComponent), 316 - 1.0) 317 - } 318 - 319 - /// Switch the meter substrate + lit-bar tonality between the 320 - /// glowing-on-black look (dark mode) and an ink-on-paper look 321 - /// (light mode). In light mode the clear color flips to a warm 322 - /// off-white and the shader's hot-zone mix darkens toward black 323 - /// instead of brightening to white, so peak still reads as 324 - /// "hotter" without washing out against the light substrate. 325 - func setLightMode(_ isLight: Bool) { 326 - switch (surfaceStyle, isLight) { 327 - case (.standard, true): 328 - // Warm off-white — closer to a printed page than pure 329 - // white, so the colored bars don't vibrate against it. 330 - clearColor = MTLClearColor(red: 0.93, green: 0.92, blue: 0.90, alpha: 1.0) 331 - uniforms.isLight = 1 332 - case (.standard, false): 333 - clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1.0) 334 - uniforms.isLight = 0 335 - case (.glassEmbedded, true): 336 - clearColor = MTLClearColor(red: 0.86, green: 0.88, blue: 0.90, alpha: 1.0) 337 - uniforms.isLight = 1 338 - case (.glassEmbedded, false): 339 - clearColor = MTLClearColor(red: 0.06, green: 0.08, blue: 0.10, alpha: 1.0) 340 - uniforms.isLight = 0 341 - } 342 - requestDisplay() 343 - } 344 - 345 - func setSurfaceStyle(_ style: SurfaceStyle) { 346 - guard surfaceStyle != style else { return } 347 - surfaceStyle = style 348 - let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 349 - setLightMode(!isDark) 350 - } 351 - 352 - // MARK: - Per-frame audio analysis 353 - 354 - private func updateLevels() { 355 - guard let m = menuBand else { 356 - for i in 0..<levels.count { levels[i] = 0 } 357 - for i in 0..<displayLevels.count { displayLevels[i] = 0 } 358 - return 359 - } 360 - m.synthSnapshotWaveform(into: &samples) 361 - 362 - let n = Self.barCount 363 - let chunkSize = samples.count / n 364 - var framePeak: Float = 0 365 - // RMS per bin instead of peak. Peak detection makes bars hop 366 - // around as zero-crossings drift across chunk boundaries — looks 367 - // like the spectrum is "rolling." RMS averages within each chunk 368 - // so a small phase shift barely moves the value, and the bars 369 - // sit at their true amplitude rather than chasing transients. 370 - for b in 0..<n { 371 - var sumSq: Float = 0 372 - let base = b * chunkSize 373 - for i in 0..<chunkSize { 374 - let s = samples[base + i] 375 - sumSq += s * s 376 - } 377 - let rms = (chunkSize > 0) ? sqrtf(sumSq / Float(chunkSize)) : 0 378 - levels[b] = rms 379 - if rms > framePeak { framePeak = rms } 380 - } 381 - 382 - // Auto-gain — same envelope as the old CALayer path. Snap up on 383 - // attack, decay slowly on release so a sustained quiet note still 384 - // pushes bars up. 385 - if framePeak > smoothedPeak { 386 - smoothedPeak = framePeak 387 - } else { 388 - smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 389 - } 390 - let gain = 0.95 / smoothedPeak 391 - // Per-bar temporal smoothing. RMS over a short chunk still 392 - // wobbles when the chunk is shorter than one period of the note 393 - // (low pitches especially) — a 30 Hz tone has ~1500 samples per 394 - // period, but each bar only sees ~32 samples. Without temporal 395 - // smoothing, those phase-induced wobbles drove the LED segment 396 - // count up and down frame to frame, reading as the meter 397 - // "rolling." Asymmetric smoothing (fast attack, slow decay) 398 - // keeps transient response while killing the ripple. 399 - let attack: Float = 0.55 // weight on new sample when rising 400 - let decay: Float = 0.18 // weight on new sample when falling 401 - for b in 0..<n { 402 - let raw = min(1.0, levels[b] * gain) 403 - let prev = displayLevels[b] 404 - let alpha = (raw > prev) ? attack : decay 405 - displayLevels[b] = prev * (1.0 - alpha) + raw * alpha 406 - } 407 - } 408 - } 409 - 410 - private struct BarUniforms { 411 - var viewW: Float = 0 412 - var viewH: Float = 0 413 - var barW: Float = 0 414 - var stride: Float = 0 415 - var minHeight: Float = 1.5 416 - var color: SIMD4<Float> = SIMD4<Float>(0, 1, 1, 1) 417 - /// Set to 1 when the bars should render the dot-matrix pattern 418 - /// (see `dotMasks`) instead of the continuous-level VU. Used in 419 - /// MIDI mode to spell "MIDI" out of the LED segments. 420 - var dotMatrix: Float = 0 421 - /// Set to 1 when the popover is in light appearance — flips the 422 - /// hot-zone mix target from white to black and the unlit fade 423 - /// target from black to the warm off-white substrate. 424 - var isLight: Float = 0 425 - } 426 - 427 - extension WaveformView: MTKViewDelegate { 428 - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 429 - 430 - func draw(in view: MTKView) { 431 - guard canDrawSurface else { return } 432 - updateLevels() 433 - 434 - let drawableSize = view.drawableSize 435 - let viewW = Float(drawableSize.width) 436 - let viewH = Float(drawableSize.height) 437 - let n = Self.barCount 438 - // Drawable size is in pixels; gap is in points, so scale up by 439 - // contentsScale (Retina = 2). Without this the gaps look half-width 440 - // on Retina displays. 441 - let scale = Float(layer?.contentsScale ?? 2.0) 442 - let gapPx = Self.barGapPts * scale 443 - let barW = max(1, (viewW - gapPx * Float(n - 1)) / Float(n)) 444 - let stride = barW + gapPx 445 - 446 - uniforms.viewW = viewW 447 - uniforms.viewH = viewH 448 - uniforms.barW = barW 449 - uniforms.stride = stride 450 - uniforms.minHeight = 1.5 * scale 451 - 452 - guard let pipeline = pipelineState, 453 - let queue = commandQueue, 454 - let descriptor = view.currentRenderPassDescriptor, 455 - let drawable = view.currentDrawable, 456 - let cb = queue.makeCommandBuffer(), 457 - let enc = cb.makeRenderCommandEncoder(descriptor: descriptor) else { 458 - return 459 - } 460 - 461 - enc.setRenderPipelineState(pipeline) 462 - enc.setVertexBytes(&uniforms, length: MemoryLayout<BarUniforms>.stride, index: 0) 463 - enc.setFragmentBytes(&uniforms, length: MemoryLayout<BarUniforms>.stride, index: 0) 464 - displayLevels.withUnsafeBufferPointer { ptr in 465 - enc.setVertexBytes(ptr.baseAddress!, 466 - length: MemoryLayout<Float>.size * n, 467 - index: 1) 468 - } 469 - dotMasks.withUnsafeBufferPointer { ptr in 470 - enc.setVertexBytes(ptr.baseAddress!, 471 - length: MemoryLayout<Float>.size * n, 472 - index: 2) 473 - } 474 - enc.drawPrimitives(type: .triangle, 475 - vertexStart: 0, 476 - vertexCount: 6, 477 - instanceCount: n) 478 - enc.endEncoding() 479 - cb.present(drawable) 480 - cb.commit() 481 - } 482 - }