Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

Refine floating palette shortcuts and layout

Add a palette-specific keyboard renderer layout so the floating piano no longer keeps the menu bar's left-side negative space. Wire focused floating-palette keyboard handling so the focus shortcut exits cleanly back to the prior app, add a keyboard layout toggle shortcut, and surface the shortcut hints in the palette UI.

authored by

Esteban Uribe and committed by
prompt.ac/@jeffrey
2816b6f9 c9873c03

+1001 -39
+152 -18
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 9 9 private var trackingArea: NSTrackingArea? 10 10 private var typeModeHotkey: GlobalHotkey? 11 11 private var focusCaptureHotkey: GlobalHotkey? 12 + private var playPaletteHotkey: GlobalHotkey? 13 + private var layoutToggleHotkey: GlobalHotkey? 12 14 private let popover = NSPopover() 13 15 private var popoverVC: MenuBandPopoverViewController? 16 + private lazy var floatingPlayPalette = FloatingPlayPaletteController(menuBand: menuBand) 17 + private var appBeforePopover: NSRunningApplication? 14 18 private var appBeforeFocusCapture: NSRunningApplication? 15 19 private var focusCaptureArmedByShortcut = false 16 20 ··· 109 113 self.kickIconAnim(slide: dir, flash: 1.0) 110 114 } 111 115 self.updateIcon() 116 + self.floatingPlayPalette.refresh() 112 117 // Refresh the popover too so live state changes 113 118 // (octave shift via , / . , MIDI mode flip, etc.) 114 119 // reflect immediately while the popover is open. ··· 129 134 self.lastLitCount = cur 130 135 self.updateIcon() 131 136 self.popoverVC?.refreshHeldNotes() 137 + self.floatingPlayPalette.refresh() 132 138 } 133 139 menuBand.bootstrap() 140 + floatingPlayPalette.onDismiss = { [weak self] in 141 + self?.updateIcon() 142 + self?.popoverVC?.syncFromController() 143 + } 144 + floatingPlayPalette.isPianoFocusActive = { [weak self] in 145 + self?.localCapture.isArmed ?? false 146 + } 147 + floatingPlayPalette.onFocusRelease = { [weak self] in 148 + self?.finishFloatingPaletteKeyboardFocus() 149 + } 150 + floatingPlayPalette.onToggleKeymap = { [weak self] in 151 + self?.toggleKeyboardLayoutShortcut() 152 + } 134 153 135 154 statusItem = NSStatusBar.system.statusItem(withLength: KeyboardIconRenderer.imageSize.width) 136 155 debugLog("statusItem created, button=\(statusItem.button != nil) length=\(statusItem.length)") ··· 161 180 162 181 registerTypeModeHotkey() 163 182 _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) 183 + _ = registerPlayPaletteHotkey(MenuBandShortcutPreferences.playPaletteShortcut) 184 + registerLayoutToggleHotkey() 164 185 165 186 // Dev affordance: post the 166 187 // `computer.aestheticcomputer.menuband.showPopover` ··· 186 207 self.localCapture.disarm(reason: .cancelled) 187 208 return true 188 209 } 210 + if isDown && MenuBandShortcut.layoutToggle.matches( 211 + keyCode: UInt32(keyCode), 212 + modifiers: MenuBandShortcut.carbonModifiers(from: flags) 213 + ) { 214 + self.toggleKeyboardLayoutShortcut() 215 + return true 216 + } 189 217 let consumed = self.menuBand.handleLocalKey( 190 218 keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags 191 219 ) ··· 218 246 vc.onFocusShortcutRecordingChanged = { [weak self] isRecording in 219 247 self?.setShortcutRecording(isRecording) 220 248 } 249 + vc.onPlayPaletteToggle = { [weak self] in 250 + self?.togglePlayPaletteFromCommand() 251 + } 252 + vc.onPlayPaletteShortcutChange = { [weak self] shortcut in 253 + self?.applyPlayPaletteShortcut(shortcut) ?? false 254 + } 255 + vc.onPlayPaletteShortcutRecordingChanged = { [weak self] isRecording in 256 + self?.setShortcutRecording(isRecording) 257 + } 258 + vc.isPlayPaletteShown = { [weak self] in 259 + self?.floatingPlayPalette.isShown ?? false 260 + } 221 261 popoverVC = vc 222 262 popover.contentViewController = vc 223 263 // .applicationDefined: never auto-close. We manage closing manually ··· 257 297 } 258 298 } 259 299 300 + private func registerLayoutToggleHotkey() { 301 + let hotkey = GlobalHotkey( 302 + signature: OSType(0x4D424C54), // 'MBLT' 303 + id: 1 304 + ) { [weak self] in 305 + self?.toggleKeyboardLayoutShortcut() 306 + } 307 + if hotkey.register( 308 + keyCode: MenuBandShortcut.layoutToggle.keyCode, 309 + modifiers: MenuBandShortcut.layoutToggle.modifiers 310 + ) { 311 + layoutToggleHotkey = hotkey 312 + } 313 + } 314 + 315 + @discardableResult 316 + private func registerPlayPaletteHotkey(_ shortcut: MenuBandShortcut) -> Bool { 317 + let hotkey = GlobalHotkey( 318 + signature: OSType(0x4D425050), // 'MBPP' 319 + id: 1 320 + ) { [weak self] in 321 + self?.togglePlayPaletteFromShortcut() 322 + } 323 + guard hotkey.register(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) else { 324 + return false 325 + } 326 + playPaletteHotkey = hotkey 327 + return true 328 + } 329 + 260 330 @discardableResult 261 331 private func registerFocusCaptureHotkey(_ shortcut: MenuBandShortcut) -> Bool { 262 332 let hotkey = GlobalHotkey( ··· 274 344 } 275 345 276 346 private func applyFocusShortcut(_ shortcut: MenuBandShortcut) -> Bool { 277 - guard shortcut.isValidForRecording, !shortcut.isReservedForTypeMode else { 347 + guard shortcut.isValidForRecording, 348 + !shortcut.isReservedForTypeMode, 349 + shortcut != MenuBandShortcutPreferences.playPaletteShortcut else { 278 350 return false 279 351 } 280 352 let previous = MenuBandShortcutPreferences.focusShortcut ··· 288 360 return true 289 361 } 290 362 363 + private func applyPlayPaletteShortcut(_ shortcut: MenuBandShortcut) -> Bool { 364 + guard shortcut.isValidForRecording, 365 + !shortcut.isReservedForTypeMode, 366 + shortcut != MenuBandShortcutPreferences.focusShortcut else { 367 + return false 368 + } 369 + let previous = MenuBandShortcutPreferences.playPaletteShortcut 370 + playPaletteHotkey?.unregister() 371 + playPaletteHotkey = nil 372 + guard registerPlayPaletteHotkey(shortcut) else { 373 + _ = registerPlayPaletteHotkey(previous) 374 + return false 375 + } 376 + MenuBandShortcutPreferences.playPaletteShortcut = shortcut 377 + return true 378 + } 379 + 291 380 private func setShortcutRecording(_ isRecording: Bool) { 292 381 if isRecording { 293 382 typeModeHotkey?.unregister() 294 383 typeModeHotkey = nil 295 384 focusCaptureHotkey?.unregister() 296 385 focusCaptureHotkey = nil 386 + playPaletteHotkey?.unregister() 387 + playPaletteHotkey = nil 388 + layoutToggleHotkey?.unregister() 389 + layoutToggleHotkey = nil 297 390 } else { 298 391 if typeModeHotkey == nil { registerTypeModeHotkey() } 299 392 if focusCaptureHotkey == nil { 300 393 _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) 301 394 } 395 + if playPaletteHotkey == nil { 396 + _ = registerPlayPaletteHotkey(MenuBandShortcutPreferences.playPaletteShortcut) 397 + } 398 + if layoutToggleHotkey == nil { registerLayoutToggleHotkey() } 399 + } 400 + } 401 + 402 + private func togglePlayPaletteFromShortcut() { 403 + if floatingPlayPalette.isShown { 404 + floatingPlayPalette.toggleFromShortcut() 405 + return 406 + } 407 + beginFloatingPlayPalette() 408 + floatingPlayPalette.toggleFromShortcut() 409 + } 410 + 411 + private func togglePlayPaletteFromCommand() { 412 + let appToRestore = appBeforePopover 413 + beginFloatingPlayPalette() 414 + appBeforePopover = nil 415 + floatingPlayPalette.showFromCommand(restoringTo: appToRestore) 416 + } 417 + 418 + private func beginFloatingPlayPalette() { 419 + closePopover() 420 + if localCapture.isArmed { 421 + localCapture.disarm(reason: .resignedKey) 422 + } 423 + if menuBand.typeMode { 424 + menuBand.disableTypeModeForFocusCapture() 302 425 } 303 426 } 304 427 305 428 private func toggleFocusCaptureFromShortcut() { 429 + if floatingPlayPalette.isKeyboardFocused { 430 + finishFloatingPaletteKeyboardFocus() 431 + return 432 + } 306 433 if localCapture.isArmed, focusCaptureArmedByShortcut { 307 434 localCapture.disarm(reason: .cancelled) 308 435 return ··· 324 451 focusCaptureArmedByShortcut = true 325 452 localCapture.arm() 326 453 updateIcon() 454 + floatingPlayPalette.refresh() 327 455 } 328 456 329 457 private func finishLocalCapture(reason: LocalKeyCapture.EndReason) { ··· 334 462 ghostRefreshTimer?.invalidate() 335 463 ghostRefreshTimer = nil 336 464 updateIcon() 465 + floatingPlayPalette.refresh() 337 466 if shouldRestoreFocus { 338 467 restorePreviousAppFocus() 339 468 } ··· 345 474 !app.isTerminated, 346 475 app.bundleIdentifier != Bundle.main.bundleIdentifier else { return } 347 476 app.activate(options: [.activateIgnoringOtherApps]) 477 + } 478 + 479 + private func finishFloatingPaletteKeyboardFocus() { 480 + menuBand.releaseAllHeldNotes() 481 + floatingPlayPalette.clearInteraction() 482 + floatingPlayPalette.releaseKeyboardFocus() 483 + updateIcon() 484 + floatingPlayPalette.refresh() 485 + } 486 + 487 + private func toggleKeyboardLayoutShortcut() { 488 + menuBand.keymap = (menuBand.keymap == .ableton) ? .notepat : .ableton 348 489 } 349 490 350 491 // MARK: - Adaptive menubar layout ··· 427 568 } 428 569 429 570 func applicationWillTerminate(_ notification: Notification) { 571 + floatingPlayPalette.dismiss(reason: .programmatic) 430 572 menuBand.shutdown() 431 573 } 432 574 ··· 799 941 } 800 942 801 943 let initialPt = imagePoint(from: downEvent.locationInWindow) 802 - let (vel0, pan0) = expression(for: startNote, at: initialPt) 944 + let (vel0, pan0) = NoteExpression.values(for: startNote, at: initialPt) 803 945 menuBand.startTapNote(startNote, velocity: vel0, pan: pan0) 804 946 // Arm sandbox-friendly local capture on a real piano click. We 805 947 // skip arming when global TYPE mode is already on — the global ··· 809 951 // when you're just tapping the piano with the mouse. 810 952 if !menuBand.typeMode { 811 953 localCapture.arm() 954 + floatingPlayPalette.refresh() 812 955 } 813 956 var current: UInt8? = startNote 814 957 while let next = NSApp.nextEvent( ··· 826 969 if hovered != current { 827 970 if let prev = current { menuBand.stopTapNote(prev) } 828 971 if let nxt = hovered { 829 - let (v, p) = expression(for: nxt, at: pt) 972 + let (v, p) = NoteExpression.values(for: nxt, at: pt) 830 973 menuBand.startTapNote(nxt, velocity: v, pan: p) 831 974 } 832 975 current = hovered 833 976 } else if let c = current { 834 - let (_, p) = expression(for: c, at: pt) 977 + let (_, p) = NoteExpression.values(for: c, at: pt) 835 978 menuBand.updateTapPan(c, pan: p) 836 979 } 837 980 } 838 981 } 839 982 840 - private func expression(for midiNote: UInt8, at pt: NSPoint) -> (UInt8, UInt8) { 841 - guard let rect = KeyboardIconRenderer.keyRect(for: midiNote) else { 842 - return (100, 64) 843 - } 844 - let xRel = max(0, min(1, (pt.x - rect.minX) / rect.width)) 845 - let yRel = max(0, min(1, (pt.y - rect.minY) / rect.height)) 846 - let pan = UInt8(max(0, min(127, Int(round(xRel * 127))))) 847 - let yDist = abs(yRel - 0.5) * 2.0 848 - let vMin: Double = 60, vMax: Double = 120 849 - let vel = vMax - (vMax - vMin) * yDist 850 - let velocity = UInt8(max(1, min(127, Int(round(vel))))) 851 - return (velocity, pan) 852 - } 853 - 854 983 // MARK: - Popover 855 984 856 985 /// Close the popover and tear down the click-away monitor. Called from ··· 862 991 } 863 992 if let m = clickAwayMonitor { NSEvent.removeMonitor(m); clickAwayMonitor = nil } 864 993 if let m = popoverEscMonitor { NSEvent.removeMonitor(m); popoverEscMonitor = nil } 994 + appBeforePopover = nil 865 995 } 866 996 867 997 /// Distributed-notification entry point for the dev affordance ··· 900 1030 // register immediately. NSStatusItem popovers don't pull focus 901 1031 // by default; without this you have to click into the popover 902 1032 // once before its controls react. 1033 + let frontmost = NSWorkspace.shared.frontmostApplication 1034 + appBeforePopover = frontmost?.bundleIdentifier == Bundle.main.bundleIdentifier 1035 + ? nil 1036 + : frontmost 903 1037 NSApp.activate(ignoringOtherApps: true) 904 1038 popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 905 1039 DispatchQueue.main.async {
+720
slab/menuband/Sources/MenuBand/FloatingPlayPalette.swift
··· 1 + import AppKit 2 + 3 + final class FloatingPlayPaletteController: NSObject, NSWindowDelegate { 4 + enum DismissReason { 5 + case closeButton 6 + case shortcut 7 + case programmatic 8 + 9 + var shouldRestoreFocus: Bool { 10 + switch self { 11 + case .closeButton, .shortcut: 12 + return true 13 + case .programmatic: 14 + return false 15 + } 16 + } 17 + } 18 + 19 + private let menuBand: MenuBandController 20 + private let viewController: FloatingPlayPaletteViewController 21 + private var panel: FloatingPlayPalettePanel? 22 + private var keyMonitor: Any? 23 + private var appBeforeOpen: NSRunningApplication? 24 + private var isDismissing = false 25 + 26 + var onDismiss: (() -> Void)? 27 + var onFocusRelease: (() -> Void)? 28 + var onToggleKeymap: (() -> Void)? 29 + var isPianoFocusActive: (() -> Bool)? { 30 + get { viewController.isPianoFocusActive } 31 + set { viewController.isPianoFocusActive = newValue } 32 + } 33 + 34 + var isShown: Bool { 35 + panel?.isVisible == true 36 + } 37 + 38 + init(menuBand: MenuBandController) { 39 + self.menuBand = menuBand 40 + self.viewController = FloatingPlayPaletteViewController(menuBand: menuBand) 41 + super.init() 42 + self.viewController.onClose = { [weak self] in 43 + self?.dismiss(reason: .closeButton) 44 + } 45 + } 46 + 47 + func toggleFromShortcut() { 48 + if isShown { 49 + dismiss(reason: .shortcut) 50 + } else { 51 + show() 52 + } 53 + } 54 + 55 + func showFromCommand(restoringTo previousApp: NSRunningApplication? = nil) { 56 + show(restoringTo: previousApp) 57 + } 58 + 59 + func show(restoringTo previousApp: NSRunningApplication? = nil) { 60 + if panel == nil { buildPanel() } 61 + guard let panel = panel else { return } 62 + 63 + if panel.isVisible { 64 + panel.makeKeyAndOrderFront(nil) 65 + return 66 + } 67 + 68 + appBeforeOpen = previousApp ?? currentFrontmostOtherApp() 69 + viewController.refresh() 70 + panel.setFrame(frameForCurrentMouseScreen(size: viewController.preferredContentSize), display: false) 71 + 72 + NSApp.activate(ignoringOtherApps: true) 73 + panel.makeKeyAndOrderFront(nil) 74 + viewController.setPresented(true) 75 + installMonitors() 76 + } 77 + 78 + func dismiss(reason: DismissReason = .programmatic) { 79 + guard !isDismissing else { return } 80 + guard isShown || keyMonitor != nil else { return } 81 + 82 + isDismissing = true 83 + removeMonitors() 84 + viewController.setPresented(false) 85 + viewController.clearInteraction() 86 + menuBand.releaseAllHeldNotes() 87 + panel?.orderOut(nil) 88 + onDismiss?() 89 + if reason.shouldRestoreFocus { 90 + restorePreviousAppFocus() 91 + } 92 + appBeforeOpen = nil 93 + isDismissing = false 94 + } 95 + 96 + func refresh() { 97 + guard isShown else { return } 98 + viewController.refresh() 99 + resizePanelToCurrentContent() 100 + } 101 + 102 + func clearInteraction() { 103 + viewController.clearInteraction() 104 + } 105 + 106 + var isKeyboardFocused: Bool { 107 + panel?.isKeyWindow == true 108 + } 109 + 110 + func releaseKeyboardFocus() { 111 + guard isShown else { return } 112 + restorePreviousAppFocus() 113 + } 114 + 115 + private func buildPanel() { 116 + let p = FloatingPlayPalettePanel( 117 + contentRect: NSRect(origin: .zero, size: viewController.preferredContentSize), 118 + styleMask: [.borderless], 119 + backing: .buffered, 120 + defer: false 121 + ) 122 + p.contentViewController = viewController 123 + p.delegate = self 124 + p.isOpaque = false 125 + p.backgroundColor = .clear 126 + p.hasShadow = true 127 + p.level = .floating 128 + p.animationBehavior = .none 129 + p.collectionBehavior = [.transient] 130 + p.hidesOnDeactivate = false 131 + p.canHide = false 132 + p.isMovableByWindowBackground = false 133 + p.acceptsMouseMovedEvents = true 134 + panel = p 135 + } 136 + 137 + private func installMonitors() { 138 + if keyMonitor == nil { 139 + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 140 + guard let self = self, self.panel?.isKeyWindow == true else { return event } 141 + let isDown = event.type == .keyDown 142 + if isDown && event.keyCode == 53 /* kVK_Escape */ { 143 + self.onFocusRelease?() 144 + return nil 145 + } 146 + if isDown && MenuBandShortcutPreferences.focusShortcut.matches(event: event) { 147 + self.onFocusRelease?() 148 + return nil 149 + } 150 + if isDown && MenuBandShortcut.layoutToggle.matches(event: event) { 151 + self.onToggleKeymap?() 152 + return nil 153 + } 154 + let consumed = self.menuBand.handleLocalKey( 155 + keyCode: event.keyCode, 156 + isDown: isDown, 157 + isRepeat: event.isARepeat, 158 + flags: event.modifierFlags 159 + ) 160 + if consumed { 161 + self.refresh() 162 + return nil 163 + } 164 + return event 165 + } 166 + } 167 + } 168 + 169 + private func removeMonitors() { 170 + if let m = keyMonitor { 171 + NSEvent.removeMonitor(m) 172 + keyMonitor = nil 173 + } 174 + } 175 + 176 + private func resizePanelToCurrentContent() { 177 + guard let panel = panel, panel.isVisible else { return } 178 + let size = viewController.preferredContentSize 179 + let old = panel.frame 180 + guard abs(old.width - size.width) > 0.5 || abs(old.height - size.height) > 0.5 else { return } 181 + let center = NSPoint(x: old.midX, y: old.midY) 182 + var frame = NSRect( 183 + x: center.x - size.width / 2, 184 + y: center.y - size.height / 2, 185 + width: size.width, 186 + height: size.height 187 + ) 188 + let visible = (panel.screen ?? NSScreen.main)?.visibleFrame 189 + ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 190 + frame = clamped(frame, to: visible) 191 + panel.setFrame(frame, display: true) 192 + } 193 + 194 + private func frameForCurrentMouseScreen(size: NSSize) -> NSRect { 195 + let mouse = NSEvent.mouseLocation 196 + let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } 197 + ?? NSScreen.main 198 + ?? NSScreen.screens.first 199 + let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1024, height: 768) 200 + let margin: CGFloat = 16 201 + let preferred = NSPoint( 202 + x: visible.midX - size.width / 2, 203 + y: visible.midY + visible.height * 0.12 - size.height / 2 204 + ) 205 + let x = min(max(preferred.x, visible.minX + margin), visible.maxX - size.width - margin) 206 + let y = min(max(preferred.y, visible.minY + margin), visible.maxY - size.height - margin) 207 + return NSRect(origin: NSPoint(x: x, y: y), size: size) 208 + } 209 + 210 + private func clamped(_ frame: NSRect, to visible: NSRect) -> NSRect { 211 + let margin: CGFloat = 16 212 + let x = min(max(frame.origin.x, visible.minX + margin), visible.maxX - frame.width - margin) 213 + let y = min(max(frame.origin.y, visible.minY + margin), visible.maxY - frame.height - margin) 214 + return NSRect(origin: NSPoint(x: x, y: y), size: frame.size) 215 + } 216 + 217 + private func currentFrontmostOtherApp() -> NSRunningApplication? { 218 + let frontmost = NSWorkspace.shared.frontmostApplication 219 + guard frontmost?.bundleIdentifier != Bundle.main.bundleIdentifier else { return nil } 220 + return frontmost 221 + } 222 + 223 + private func restorePreviousAppFocus() { 224 + guard let app = appBeforeOpen, 225 + !app.isTerminated, 226 + app.bundleIdentifier != Bundle.main.bundleIdentifier else { return } 227 + app.activate(options: [.activateIgnoringOtherApps]) 228 + } 229 + 230 + deinit { 231 + dismiss(reason: .programmatic) 232 + } 233 + } 234 + 235 + private final class FloatingPlayPaletteViewController: NSViewController { 236 + private let paletteView: FloatingPlayPaletteView 237 + var onClose: (() -> Void)? { 238 + get { paletteView.onClose } 239 + set { paletteView.onClose = newValue } 240 + } 241 + var isPianoFocusActive: (() -> Bool)? { 242 + get { paletteView.isPianoFocusActive } 243 + set { paletteView.isPianoFocusActive = newValue } 244 + } 245 + 246 + init(menuBand: MenuBandController) { 247 + self.paletteView = FloatingPlayPaletteView(menuBand: menuBand) 248 + super.init(nibName: nil, bundle: nil) 249 + preferredContentSize = paletteView.fittingSize 250 + } 251 + 252 + @available(*, unavailable) 253 + required init?(coder: NSCoder) { 254 + nil 255 + } 256 + 257 + override func loadView() { 258 + view = paletteView 259 + } 260 + 261 + func refresh() { 262 + paletteView.refresh() 263 + preferredContentSize = paletteView.fittingSize 264 + } 265 + 266 + func clearInteraction() { 267 + paletteView.clearInteraction() 268 + } 269 + 270 + func setPresented(_ isPresented: Bool) { 271 + paletteView.setPresented(isPresented) 272 + } 273 + } 274 + 275 + private final class FloatingPlayPaletteView: NSView { 276 + private weak var menuBand: MenuBandController? 277 + private let waveformView = WaveformView() 278 + private let pianoView: FloatingPianoView 279 + private let dragHandle = FloatingPaletteDragHandleView() 280 + private let closeButton = NSButton() 281 + private let shortcutHintLabel = NSTextField(labelWithString: "") 282 + 283 + var onClose: (() -> Void)? 284 + var isPianoFocusActive: (() -> Bool)? 285 + 286 + private let pianoScale: CGFloat = 2.0 287 + private let inset: CGFloat = 14 288 + private let gap: CGFloat = 8 289 + private let closeSize: CGFloat = 18 290 + private let hintHeight: CGFloat = 42 291 + private var waveformHeightConstraint: NSLayoutConstraint? 292 + 293 + init(menuBand: MenuBandController) { 294 + self.menuBand = menuBand 295 + self.pianoView = FloatingPianoView(menuBand: menuBand, pianoScale: pianoScale) 296 + super.init(frame: NSRect(origin: .zero, size: .zero)) 297 + wantsLayer = true 298 + 299 + waveformView.menuBand = menuBand 300 + waveformView.translatesAutoresizingMaskIntoConstraints = false 301 + pianoView.translatesAutoresizingMaskIntoConstraints = false 302 + dragHandle.translatesAutoresizingMaskIntoConstraints = false 303 + closeButton.translatesAutoresizingMaskIntoConstraints = false 304 + shortcutHintLabel.translatesAutoresizingMaskIntoConstraints = false 305 + addSubview(waveformView) 306 + addSubview(pianoView) 307 + shortcutHintLabel.font = NSFont.systemFont(ofSize: 10) 308 + shortcutHintLabel.textColor = .secondaryLabelColor 309 + shortcutHintLabel.alignment = .center 310 + shortcutHintLabel.maximumNumberOfLines = 2 311 + shortcutHintLabel.lineBreakMode = .byWordWrapping 312 + addSubview(shortcutHintLabel) 313 + addSubview(dragHandle) 314 + updateShortcutHint() 315 + 316 + let closeConfig = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) 317 + closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? 318 + .withSymbolConfiguration(closeConfig) 319 + closeButton.isBordered = false 320 + closeButton.imagePosition = .imageOnly 321 + closeButton.contentTintColor = .secondaryLabelColor 322 + closeButton.toolTip = "Close" 323 + closeButton.target = self 324 + closeButton.action = #selector(closeClicked(_:)) 325 + addSubview(closeButton) 326 + 327 + let keyboardSize = self.keyboardSize() 328 + let waveformHeightConstraint = waveformView.heightAnchor.constraint( 329 + equalToConstant: waveformHeight(for: keyboardSize) 330 + ) 331 + self.waveformHeightConstraint = waveformHeightConstraint 332 + 333 + NSLayoutConstraint.activate([ 334 + widthAnchor.constraint(equalToConstant: keyboardSize.width + inset * 2), 335 + 336 + closeButton.topAnchor.constraint(equalTo: topAnchor, constant: inset), 337 + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 338 + closeButton.widthAnchor.constraint(equalToConstant: closeSize), 339 + closeButton.heightAnchor.constraint(equalToConstant: closeSize), 340 + 341 + dragHandle.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 342 + dragHandle.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -gap), 343 + dragHandle.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), 344 + dragHandle.heightAnchor.constraint(equalToConstant: closeSize), 345 + 346 + waveformView.topAnchor.constraint(equalTo: closeButton.bottomAnchor, constant: gap), 347 + waveformView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 348 + waveformView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 349 + waveformHeightConstraint, 350 + 351 + pianoView.topAnchor.constraint(equalTo: waveformView.bottomAnchor, constant: gap), 352 + pianoView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 353 + pianoView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 354 + 355 + shortcutHintLabel.topAnchor.constraint(equalTo: pianoView.bottomAnchor, constant: gap), 356 + shortcutHintLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 357 + shortcutHintLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 358 + shortcutHintLabel.heightAnchor.constraint(equalToConstant: hintHeight), 359 + shortcutHintLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset) 360 + ]) 361 + } 362 + 363 + @available(*, unavailable) 364 + required init?(coder: NSCoder) { 365 + nil 366 + } 367 + 368 + override var acceptsFirstResponder: Bool { true } 369 + 370 + override var fittingSize: NSSize { 371 + pianoView.refreshLayout() 372 + layoutSubtreeIfNeeded() 373 + return super.fittingSize 374 + } 375 + 376 + override func draw(_ dirtyRect: NSRect) { 377 + super.draw(dirtyRect) 378 + let background = bounds.insetBy(dx: 0.5, dy: 0.5) 379 + let path = NSBezierPath(roundedRect: background, xRadius: 13, yRadius: 13) 380 + NSColor.windowBackgroundColor.withAlphaComponent(0.96).setFill() 381 + path.fill() 382 + NSColor.separatorColor.withAlphaComponent(0.55).setStroke() 383 + path.lineWidth = 1 384 + path.stroke() 385 + } 386 + 387 + func refresh() { 388 + updateShortcutHint() 389 + let keyboardSize = keyboardSize() 390 + waveformHeightConstraint?.constant = waveformHeight(for: keyboardSize) 391 + pianoView.refreshLayout() 392 + layoutSubtreeIfNeeded() 393 + updateWaveformLiveState(isPresented: window?.isVisible == true) 394 + needsDisplay = true 395 + pianoView.needsDisplay = true 396 + } 397 + 398 + func clearInteraction() { 399 + pianoView.clearInteraction() 400 + } 401 + 402 + func setPresented(_ isPresented: Bool) { 403 + updateWaveformLiveState(isPresented: isPresented) 404 + } 405 + 406 + private func updateWaveformLiveState(isPresented: Bool) { 407 + waveformView.isLive = isPresented && !(menuBand?.midiMode ?? false) 408 + waveformView.alphaValue = (menuBand?.midiMode ?? false) ? 0.35 : 1.0 409 + } 410 + 411 + private func keyboardSize() -> NSSize { 412 + withFloatingPaletteKeyboard(menuBand: menuBand) { 413 + let piano = KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange) 414 + return NSSize(width: piano.width * pianoScale, height: piano.height * pianoScale) 415 + } 416 + } 417 + 418 + private func waveformHeight(for keyboard: NSSize) -> CGFloat { 419 + keyboard.height * 2.0 420 + } 421 + 422 + private func updateShortcutHint() { 423 + let floatingShortcut = MenuBandShortcutPreferences.playPaletteShortcut.displayString 424 + let focusShortcut = MenuBandShortcutPreferences.focusShortcut.displayString 425 + let layoutShortcut = MenuBandShortcut.layoutToggle.displayString 426 + let focusText = (isPianoFocusActive?() ?? false) 427 + ? "Exit focus: \(focusShortcut)" 428 + : "Focus piano: \(focusShortcut)" 429 + shortcutHintLabel.stringValue = 430 + "Show/hide floating piano: \(floatingShortcut)\n\(focusText) Toggle layout: \(layoutShortcut)" 431 + } 432 + 433 + @objc private func closeClicked(_ sender: NSButton) { 434 + onClose?() 435 + } 436 + } 437 + 438 + private final class FloatingPianoView: NSView { 439 + private static let rendererLayout: KeyboardIconRenderer.Layout = .tightActiveRange 440 + 441 + private weak var menuBand: MenuBandController? 442 + private var trackingArea: NSTrackingArea? 443 + private var hoveredNote: UInt8? 444 + private var currentNote: UInt8? 445 + 446 + private let pianoScale: CGFloat 447 + private var widthConstraint: NSLayoutConstraint! 448 + private var heightConstraint: NSLayoutConstraint! 449 + 450 + init(menuBand: MenuBandController, pianoScale: CGFloat) { 451 + self.menuBand = menuBand 452 + self.pianoScale = pianoScale 453 + super.init(frame: NSRect(origin: .zero, size: .zero)) 454 + wantsLayer = true 455 + 456 + let preferredSize = preferredSize() 457 + widthConstraint = widthAnchor.constraint(equalToConstant: preferredSize.width) 458 + heightConstraint = heightAnchor.constraint(equalToConstant: preferredSize.height) 459 + NSLayoutConstraint.activate([widthConstraint, heightConstraint]) 460 + } 461 + 462 + @available(*, unavailable) 463 + required init?(coder: NSCoder) { 464 + nil 465 + } 466 + 467 + override var acceptsFirstResponder: Bool { true } 468 + 469 + func refreshLayout() { 470 + let preferredSize = preferredSize() 471 + widthConstraint.constant = preferredSize.width 472 + heightConstraint.constant = preferredSize.height 473 + } 474 + 475 + private func preferredSize() -> NSSize { 476 + withFloatingPaletteKeyboard(menuBand: menuBand) { 477 + let piano = KeyboardIconRenderer.pianoImageSize(layout: Self.rendererLayout) 478 + return NSSize( 479 + width: piano.width * pianoScale, 480 + height: piano.height * pianoScale 481 + ) 482 + } 483 + } 484 + 485 + override func updateTrackingAreas() { 486 + super.updateTrackingAreas() 487 + if let trackingArea = trackingArea { 488 + removeTrackingArea(trackingArea) 489 + } 490 + let area = NSTrackingArea( 491 + rect: bounds, 492 + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], 493 + owner: self, 494 + userInfo: nil 495 + ) 496 + addTrackingArea(area) 497 + trackingArea = area 498 + } 499 + 500 + override func draw(_ dirtyRect: NSRect) { 501 + super.draw(dirtyRect) 502 + guard let menuBand = menuBand else { return } 503 + 504 + withFloatingPaletteKeyboard(menuBand: menuBand) { 505 + KeyboardIconRenderer.activeKeymap = menuBand.keymap 506 + let image = KeyboardIconRenderer.image( 507 + litNotes: menuBand.litNotes, 508 + enabled: menuBand.midiMode, 509 + typeMode: true, 510 + hovered: hoveredNote.map { .note($0) }, 511 + includeSettings: false, 512 + layout: Self.rendererLayout 513 + ) 514 + image.draw(in: pianoTargetRect()) 515 + } 516 + } 517 + 518 + override func mouseMoved(with event: NSEvent) { 519 + updateHover(with: event) 520 + } 521 + 522 + override func mouseExited(with event: NSEvent) { 523 + if hoveredNote != nil { 524 + hoveredNote = nil 525 + needsDisplay = true 526 + } 527 + } 528 + 529 + override func mouseDown(with event: NSEvent) { 530 + window?.makeKey() 531 + guard let menuBand = menuBand, 532 + let point = rendererPoint(from: event), 533 + let note = withFloatingPaletteKeyboard( 534 + menuBand: menuBand, 535 + { KeyboardIconRenderer.noteAt(point, layout: Self.rendererLayout) } 536 + ) 537 + else { return } 538 + let expression = withFloatingPaletteKeyboard(menuBand: menuBand) { 539 + NoteExpression.values(for: note, at: point, layout: Self.rendererLayout) 540 + } 541 + currentNote = note 542 + hoveredNote = note 543 + menuBand.startTapNote(note, velocity: expression.velocity, pan: expression.pan) 544 + needsDisplay = true 545 + } 546 + 547 + override func mouseDragged(with event: NSEvent) { 548 + guard let menuBand = menuBand, 549 + let point = rendererPoint(from: event) else { return } 550 + let hovered = withFloatingPaletteKeyboard( 551 + menuBand: menuBand, 552 + { KeyboardIconRenderer.noteAt(point, layout: Self.rendererLayout) } 553 + ) 554 + hoveredNote = hovered 555 + 556 + if hovered != currentNote { 557 + if let previous = currentNote { 558 + menuBand.stopTapNote(previous) 559 + } 560 + if let next = hovered { 561 + let expression = withFloatingPaletteKeyboard(menuBand: menuBand) { 562 + NoteExpression.values(for: next, at: point, layout: Self.rendererLayout) 563 + } 564 + menuBand.startTapNote(next, velocity: expression.velocity, pan: expression.pan) 565 + } 566 + currentNote = hovered 567 + } else if let current = currentNote { 568 + let expression = withFloatingPaletteKeyboard(menuBand: menuBand) { 569 + NoteExpression.values(for: current, at: point, layout: Self.rendererLayout) 570 + } 571 + menuBand.updateTapPan(current, pan: expression.pan) 572 + } 573 + needsDisplay = true 574 + } 575 + 576 + override func mouseUp(with event: NSEvent) { 577 + if let note = currentNote { 578 + menuBand?.stopTapNote(note) 579 + } 580 + currentNote = nil 581 + updateHover(with: event) 582 + } 583 + 584 + func clearInteraction() { 585 + currentNote = nil 586 + hoveredNote = nil 587 + needsDisplay = true 588 + } 589 + 590 + private func updateHover(with event: NSEvent) { 591 + guard let point = rendererPoint(from: event) else { 592 + if hoveredNote != nil { 593 + hoveredNote = nil 594 + needsDisplay = true 595 + } 596 + return 597 + } 598 + let next = withFloatingPaletteKeyboard( 599 + menuBand: menuBand, 600 + { KeyboardIconRenderer.noteAt(point, layout: Self.rendererLayout) } 601 + ) 602 + if next != hoveredNote { 603 + hoveredNote = next 604 + needsDisplay = true 605 + } 606 + } 607 + 608 + private func rendererPoint(from event: NSEvent) -> NSPoint? { 609 + let local = convert(event.locationInWindow, from: nil) 610 + let target = pianoTargetRect() 611 + let point = NSPoint( 612 + x: (local.x - target.minX) / pianoScale, 613 + y: (local.y - target.minY) / pianoScale 614 + ) 615 + let piano = withFloatingPaletteKeyboard(menuBand: menuBand) { 616 + KeyboardIconRenderer.pianoImageSize(layout: Self.rendererLayout) 617 + } 618 + guard point.x >= -KeyboardIconRenderer.whiteW, 619 + point.x <= piano.width + KeyboardIconRenderer.whiteW, 620 + point.y >= -piano.height, 621 + point.y <= piano.height * 2 else { return nil } 622 + return point 623 + } 624 + 625 + private func pianoTargetRect() -> NSRect { 626 + let piano = withFloatingPaletteKeyboard(menuBand: menuBand) { 627 + KeyboardIconRenderer.pianoImageSize(layout: Self.rendererLayout) 628 + } 629 + let size = NSSize(width: piano.width * pianoScale, height: piano.height * pianoScale) 630 + return NSRect( 631 + x: bounds.midX - size.width / 2, 632 + y: bounds.midY - size.height / 2, 633 + width: size.width, 634 + height: size.height 635 + ) 636 + } 637 + } 638 + 639 + private final class FloatingPaletteDragHandleView: NSView { 640 + private var dragStartMouse: NSPoint? 641 + private var dragStartWindowOrigin: NSPoint? 642 + 643 + override init(frame frameRect: NSRect) { 644 + super.init(frame: frameRect) 645 + let pan = NSPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) 646 + addGestureRecognizer(pan) 647 + } 648 + 649 + @available(*, unavailable) 650 + required init?(coder: NSCoder) { 651 + nil 652 + } 653 + 654 + override func draw(_ dirtyRect: NSRect) { 655 + super.draw(dirtyRect) 656 + let gripWidth: CGFloat = 34 657 + let gripHeight: CGFloat = 3 658 + let grip = NSRect( 659 + x: bounds.midX - gripWidth / 2, 660 + y: bounds.midY - gripHeight / 2, 661 + width: gripWidth, 662 + height: gripHeight 663 + ) 664 + NSColor.tertiaryLabelColor.withAlphaComponent(0.55).setFill() 665 + NSBezierPath(roundedRect: grip, xRadius: gripHeight / 2, yRadius: gripHeight / 2).fill() 666 + } 667 + 668 + override func resetCursorRects() { 669 + super.resetCursorRects() 670 + addCursorRect(bounds, cursor: .openHand) 671 + } 672 + 673 + @objc private func handlePan(_ recognizer: NSPanGestureRecognizer) { 674 + switch recognizer.state { 675 + case .began: 676 + dragStartMouse = NSEvent.mouseLocation 677 + dragStartWindowOrigin = window?.frame.origin 678 + NSCursor.closedHand.set() 679 + case .changed: 680 + guard let window = window, 681 + let startMouse = dragStartMouse, 682 + let startOrigin = dragStartWindowOrigin else { return } 683 + let mouse = NSEvent.mouseLocation 684 + let nextOrigin = NSPoint( 685 + x: startOrigin.x + mouse.x - startMouse.x, 686 + y: startOrigin.y + mouse.y - startMouse.y 687 + ) 688 + NSAnimationContext.runAnimationGroup { context in 689 + context.duration = 0 690 + context.allowsImplicitAnimation = false 691 + window.setFrameOrigin(nextOrigin) 692 + } 693 + case .ended, .cancelled, .failed: 694 + dragStartMouse = nil 695 + dragStartWindowOrigin = nil 696 + NSCursor.openHand.set() 697 + default: 698 + break 699 + } 700 + } 701 + } 702 + 703 + private func withFloatingPaletteKeyboard<T>(menuBand: MenuBandController?, _ body: () -> T) -> T { 704 + let oldLayout = KeyboardIconRenderer.displayLayout 705 + let oldKeymap = KeyboardIconRenderer.activeKeymap 706 + KeyboardIconRenderer.displayLayout = .full 707 + if let menuBand = menuBand { 708 + KeyboardIconRenderer.activeKeymap = menuBand.keymap 709 + } 710 + defer { 711 + KeyboardIconRenderer.displayLayout = oldLayout 712 + KeyboardIconRenderer.activeKeymap = oldKeymap 713 + } 714 + return body() 715 + } 716 + 717 + private final class FloatingPlayPalettePanel: NSPanel { 718 + override var canBecomeKey: Bool { true } 719 + override var canBecomeMain: Bool { false } 720 + }
+66 -19
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 10 10 // buttons are flat (no fill, no border) so they read like native menubar 11 11 // text. Keys are skeuomorphic: white gradient + dark-accent black gradient. 12 12 enum KeyboardIconRenderer { 13 + enum Layout { 14 + case fixedCanvas 15 + case tightActiveRange 16 + } 17 + 13 18 /// Updated by AppDelegate.updateIcon() before each render so the renderer 14 19 /// can pick the right letter labels and active-range without threading 15 20 /// the keymap through every static method's signature. ··· 153 158 return NSSize(width: totalW, height: totalH) 154 159 } 155 160 161 + static var pianoImageSize: NSSize { 162 + pianoImageSize(layout: .fixedCanvas) 163 + } 164 + 165 + static func pianoImageSize(layout: Layout) -> NSSize { 166 + let totalW = ceil(pad + pianoWidth(layout: layout) + pad) 167 + let totalH = ceil(whiteH + pad * 2) 168 + return NSSize(width: totalW, height: totalH) 169 + } 170 + 156 171 private static var pianoOriginX: CGFloat { pad } 157 172 158 173 /// Settings chip's visual rect IS its hit-test rect — they're identical ··· 166 181 hovered: HitResult? = nil, 167 182 letterAlpha: ((UInt8) -> CGFloat)? = nil, 168 183 slideOffsetX: CGFloat = 0, 169 - settingsFlash: CGFloat = 0) -> NSImage { 184 + settingsFlash: CGFloat = 0, 185 + includeSettings: Bool = true, 186 + layout: Layout = .fixedCanvas) -> NSImage { 170 187 let whites = whiteList() 171 188 var whiteIndex: [Int: Int] = [:] 172 189 for (i, m) in whites.enumerated() { whiteIndex[m] = i } 173 - let size = imageSize 190 + let size = includeSettings ? imageSize : pianoImageSize(layout: layout) 174 191 175 192 let img = NSImage(size: size, flipped: false) { _ in 176 193 ··· 225 242 // corners. Active range may be right-aligned (Ableton) so the 226 243 // outer corners sit on the first/last active white in the slot 227 244 // layout, not the geometric ends of the render area. 228 - let activeWhites = whites.filter { isActive($0) } 245 + let activeWhites = activeWhites(layout: layout) 229 246 let leftmostMidi = activeWhites.first ?? firstMidi 230 247 let rightmostMidi = activeWhites.last ?? lastMidi 231 - let slotOffset = activeSlotOffset 248 + let slotOffset = slotOffset(layout: layout) 232 249 for (idx, m) in whites.enumerated() { 233 250 if !isActive(m) { continue } // negative space — skip draw 234 251 let rect = whiteRect(at: idx + slotOffset) ··· 315 332 } 316 333 NSGraphicsContext.restoreGraphicsState() 317 334 318 - // Single settings chip — glyph + color reflect MIDI/DAW state. 319 - // MIDI on → `waveform` tinted accent (signal flowing to DAW); 320 - // MIDI off → `slider.horizontal.3` in label color (generic). 321 - drawSettingsChip(in: settingsRect, hoverRect: settingsHitRect, 322 - midiOn: enabled, 323 - hovered: hovered == .openSettings, 324 - flash: settingsFlash, 325 - voiceNumber: Int(melodicProgram)) 335 + if includeSettings { 336 + // Single settings chip — glyph + color reflect MIDI/DAW state. 337 + // MIDI on → `waveform` tinted accent (signal flowing to DAW); 338 + // MIDI off → `slider.horizontal.3` in label color (generic). 339 + drawSettingsChip(in: settingsRect, hoverRect: settingsHitRect, 340 + midiOn: enabled, 341 + hovered: hovered == .openSettings, 342 + flash: settingsFlash, 343 + voiceNumber: Int(melodicProgram)) 344 + } 326 345 return true 327 346 } 328 347 img.isTemplate = false ··· 341 360 // `settingsIconRect` directly (not via this hit rect) so the 342 361 // glyph stays put even though the hit zone now covers the pad. 343 362 private static var settingsHitRect: NSRect { 344 - let leftX = pianoOriginX + pianoWidth 363 + let leftX = pianoOriginX + pianoWidth(layout: .fixedCanvas) 345 364 let rightX = imageSize.width 346 365 return NSRect(x: leftX, y: 0, width: rightX - leftX, height: imageSize.height) 347 366 } ··· 370 389 let whites = whiteList() 371 390 var whiteIndex: [Int: Int] = [:] 372 391 for (i, m) in whites.enumerated() { whiteIndex[m] = i } 373 - let slotOffset = activeSlotOffset 392 + let slotOffset = slotOffset(layout: .fixedCanvas) 374 393 // Black-key hit area = the visual blackRect. 1:1 mapping with what 375 394 // the user sees on screen — clicking on visible black triggers black, 376 395 // clicking visible white triggers white. Inactive (negative-space) ··· 401 420 402 421 /// Public lookup so callers (drag handler) can compute cursor-relative 403 422 /// expression — e.g. y→velocity, x→pan within the active key's bounds. 404 - static func keyRect(for midi: UInt8) -> NSRect? { 423 + static func keyRect(for midi: UInt8, layout: Layout = .fixedCanvas) -> NSRect? { 405 424 let m = Int(midi) 406 425 let whites = whiteList() 407 - let slotOffset = activeSlotOffset 426 + let slotOffset = slotOffset(layout: layout) 408 427 if isWhite(m) { 409 428 guard let idx = whites.firstIndex(of: m) else { return nil } 410 429 return whiteRect(at: idx + slotOffset) ··· 420 439 /// can be anywhere); horizontally tolerates a small overshoot past the 421 440 /// leftmost/rightmost white key so a drag rolling past the edge keeps 422 441 /// the edge key sounding. 423 - static func noteAt(_ point: NSPoint) -> UInt8? { 442 + static func noteAt(_ point: NSPoint, layout: Layout = .fixedCanvas) -> UInt8? { 424 443 let whites = whiteList() 425 - let activeWhites = whites.filter { isActive($0) } 444 + let activeWhites = activeWhites(layout: layout) 426 445 guard !activeWhites.isEmpty else { return nil } 427 - let slotOffset = activeSlotOffset 446 + let slotOffset = slotOffset(layout: layout) 428 447 // Active keys occupy slots [slotOffset, slotOffset + activeWhites.count - 1]. 429 448 let leftEdge = pianoOriginX + CGFloat(slotOffset) * whiteW 430 449 let rightEdge = pianoOriginX + CGFloat(slotOffset + activeWhites.count) * whiteW ··· 456 475 } 457 476 458 477 // MARK: - Layout helpers 478 + 479 + private static func activeWhites(layout: Layout) -> [Int] { 480 + let whites = whiteList() 481 + switch layout { 482 + case .fixedCanvas: 483 + return whites.filter { isActive($0) } 484 + case .tightActiveRange: 485 + return whites.filter { isActive($0) } 486 + } 487 + } 488 + 489 + private static func slotOffset(layout: Layout) -> Int { 490 + switch layout { 491 + case .fixedCanvas: 492 + return activeSlotOffset 493 + case .tightActiveRange: 494 + return 0 495 + } 496 + } 497 + 498 + private static func pianoWidth(layout: Layout) -> CGFloat { 499 + switch layout { 500 + case .fixedCanvas: 501 + return CGFloat(whiteList().count) * whiteW 502 + case .tightActiveRange: 503 + return CGFloat(activeWhites(layout: layout).count) * whiteW 504 + } 505 + } 459 506 460 507 private static func whiteRect(at index: Int) -> NSRect { 461 508 let x = pianoOriginX + CGFloat(index) * whiteW
+38 -2
slab/menuband/Sources/MenuBand/MenuBandShortcut.swift
··· 10 10 modifiers: UInt32(cmdKey | controlKey | optionKey) 11 11 ) 12 12 13 + static let defaultPlayPalette = MenuBandShortcut( 14 + keyCode: UInt32(kVK_Space), 15 + modifiers: UInt32(cmdKey | controlKey | optionKey) 16 + ) 17 + 13 18 static let typeMode = MenuBandShortcut( 14 19 keyCode: UInt32(kVK_ANSI_P), 20 + modifiers: UInt32(cmdKey | controlKey | optionKey) 21 + ) 22 + 23 + static let layoutToggle = MenuBandShortcut( 24 + keyCode: UInt32(kVK_ANSI_L), 15 25 modifiers: UInt32(cmdKey | controlKey | optionKey) 16 26 ) 17 27 ··· 33 43 } 34 44 35 45 func matches(event: NSEvent) -> Bool { 36 - UInt32(event.keyCode) == keyCode && 37 - Self.carbonModifiers(from: event.modifierFlags) == modifiers 46 + matches(keyCode: UInt32(event.keyCode), modifiers: Self.carbonModifiers(from: event.modifierFlags)) 47 + } 48 + 49 + func matches(keyCode: UInt32, modifiers: UInt32) -> Bool { 50 + self.keyCode == keyCode && self.modifiers == modifiers 38 51 } 39 52 40 53 static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { ··· 96 109 enum MenuBandShortcutPreferences { 97 110 private static let focusKeyCodeKey = "notepat.focusShortcut.keyCode" 98 111 private static let focusModifiersKey = "notepat.focusShortcut.modifiers" 112 + private static let playPaletteKeyCodeKey = "notepat.playPaletteShortcut.keyCode" 113 + private static let playPaletteModifiersKey = "notepat.playPaletteShortcut.modifiers" 99 114 100 115 static var focusShortcut: MenuBandShortcut { 101 116 get { ··· 115 130 set { 116 131 UserDefaults.standard.set(Int(newValue.keyCode), forKey: focusKeyCodeKey) 117 132 UserDefaults.standard.set(Int(newValue.modifiers), forKey: focusModifiersKey) 133 + } 134 + } 135 + 136 + static var playPaletteShortcut: MenuBandShortcut { 137 + get { 138 + let defaults = UserDefaults.standard 139 + guard defaults.object(forKey: playPaletteKeyCodeKey) != nil, 140 + defaults.object(forKey: playPaletteModifiersKey) != nil else { 141 + return .defaultPlayPalette 142 + } 143 + let shortcut = MenuBandShortcut( 144 + keyCode: UInt32(defaults.integer(forKey: playPaletteKeyCodeKey)), 145 + modifiers: UInt32(defaults.integer(forKey: playPaletteModifiersKey)) 146 + ) 147 + return shortcut.isValidForRecording && !shortcut.isReservedForTypeMode 148 + ? shortcut 149 + : .defaultPlayPalette 150 + } 151 + set { 152 + UserDefaults.standard.set(Int(newValue.keyCode), forKey: playPaletteKeyCodeKey) 153 + UserDefaults.standard.set(Int(newValue.modifiers), forKey: playPaletteModifiersKey) 118 154 } 119 155 } 120 156 }
+25
slab/menuband/Sources/MenuBand/NoteExpression.swift
··· 1 + import AppKit 2 + 3 + enum NoteExpression { 4 + static func values( 5 + for midiNote: UInt8, 6 + at point: NSPoint, 7 + layout: KeyboardIconRenderer.Layout = .fixedCanvas 8 + ) -> (velocity: UInt8, pan: UInt8) { 9 + guard let rect = KeyboardIconRenderer.keyRect(for: midiNote, layout: layout) else { 10 + return (100, 64) 11 + } 12 + return values(in: rect, at: point) 13 + } 14 + 15 + private static func values(in rect: NSRect, at point: NSPoint) -> (velocity: UInt8, pan: UInt8) { 16 + let xRel = max(0, min(1, (point.x - rect.minX) / rect.width)) 17 + let yRel = max(0, min(1, (point.y - rect.minY) / rect.height)) 18 + let pan = UInt8(max(0, min(127, Int(round(xRel * 127))))) 19 + let yDist = abs(yRel - 0.5) * 2.0 20 + let vMin: Double = 60, vMax: Double = 120 21 + let vel = vMax - (vMax - vMin) * yDist 22 + let velocity = UInt8(max(1, min(127, Int(round(vel))))) 23 + return (velocity, pan) 24 + } 25 + }