diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index eec93b5ef4..dadad5493e 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -21,6 +21,10 @@ final class InstrumentListView: NSView { var selectedProgram: UInt8 = 0 { didSet { needsDisplay = true } } private(set) var hoveredProgram: UInt8? var onCommit: ((Int) -> Void)? + /// Fires whenever the hovered cell changes (including transitions to + /// "no hover" → nil). Drives the controller's hover-preview note for + /// sonic browsing. + var onHover: ((Int?) -> Void)? private var trackingArea: NSTrackingArea? @@ -134,6 +138,7 @@ final class InstrumentListView: NSView { if let prev = hoveredProgram { hoveredProgram = nil setNeedsDisplay(cellRect(program: Int(prev))) + onHover?(nil) } } @@ -144,6 +149,7 @@ final class InstrumentListView: NSView { hoveredProgram = p if let prev = prev { setNeedsDisplay(cellRect(program: Int(prev))) } if let p = p { setNeedsDisplay(cellRect(program: Int(p))) } + onHover?(p.map { Int($0) }) } } diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index b246ab4f68..8ce43f2c58 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -43,6 +43,34 @@ final class MenuBandController { synth.snapshotWaveform(into: &dest) } + // Held preview note for sonic-browse hover over the instrument map. + // Continuous tone — switching cells stops the old note + starts a new + // one in the new program. Hover-out releases. Silent in MIDI mode + // (DAW is the audio path then; we still apply the program change so + // it's correct when the user toggles MIDI off). + private var previewNote: UInt8? + + /// Hover-preview a program in the instrument map. Pass nil when the + /// hover ends to release the held note and restore the committed + /// program. + func setInstrumentPreview(_ program: UInt8?) { + if let prev = previewNote { + synth.noteOff(prev, channel: 0) + previewNote = nil + } + guard let prog = program else { + // Hover ended — flip back to the committed program so the + // menubar piano still plays whatever the user actually picked. + synth.setMelodicProgram(melodicProgram) + return + } + synth.setMelodicProgram(prog) + guard !midiMode else { return } + let note: UInt8 = 60 + synth.noteOn(note, velocity: 75, channel: 0) + previewNote = note + } + func auditionCurrentProgram() { let note: UInt8 = 60 debugLog("audition: synth.noteOn 60 (program \(melodicProgram))") diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index 89cf243923..756920b7cd 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -93,29 +93,14 @@ final class MenuBandPopoverViewController: NSViewController { stack.translatesAutoresizingMaskIntoConstraints = false root.addSubview(stack) - // Title row: app name on the left, octave control hugging the right. - // Octave gets its own row in the popover via this top-anchored - // arrangement so we don't need a dedicated "Octave" panel below. + // Top control row: octave + MIDI hugging the right. Brand title + // moved into the About section below — fewer wasted rows up top. let titleRow = NSStackView() titleRow.orientation = .horizontal titleRow.alignment = .centerY titleRow.distribution = .fill titleRow.spacing = 8 - let titleStack = NSStackView() - titleStack.orientation = .vertical - titleStack.alignment = .leading - titleStack.spacing = 0 - let title = NSTextField(labelWithString: "Menu Band") - title.font = NSFont.systemFont(ofSize: 13, weight: .bold) - title.textColor = .labelColor - let subtitle = NSTextField(labelWithString: "Built-in macOS instruments, in the menu bar.") - subtitle.font = NSFont.systemFont(ofSize: 10.5) - subtitle.textColor = .secondaryLabelColor - titleStack.addArrangedSubview(title) - titleStack.addArrangedSubview(subtitle) - titleRow.addArrangedSubview(titleStack) - let titleSpacer = NSView() titleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) titleRow.addArrangedSubview(titleSpacer) @@ -227,7 +212,7 @@ final class MenuBandPopoverViewController: NSViewController { updateLink.bottomAnchor.constraint(equalTo: updateBanner.bottomAnchor, constant: -7), ]) stack.addArrangedSubview(updateBanner) - updateBanner.widthAnchor.constraint(equalToConstant: 248).isActive = true + updateBanner.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true updateBanner.isHidden = true stack.addArrangedSubview(makeSeparator()) @@ -253,7 +238,7 @@ final class MenuBandPopoverViewController: NSViewController { inputSegmented.translatesAutoresizingMaskIntoConstraints = false // No hover preview — clicks commit the mode directly. stack.addArrangedSubview(inputSegmented) - inputSegmented.widthAnchor.constraint(equalToConstant: 248).isActive = true + inputSegmented.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true let inputHint = NSTextField(labelWithString: "⌃⌥⌘P toggles last keystrokes mode") @@ -298,6 +283,11 @@ final class MenuBandPopoverViewController: NSViewController { instrumentList.onCommit = { [weak self] prog in self?.handleInstrumentCommit(prog) } + instrumentList.onHover = { [weak self] prog in + // Hover plays a continuous preview note in the hovered program; + // moving to another cell stops + restarts in the new program. + self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) + } stack.addArrangedSubview(instrumentList) instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight).isActive = true @@ -327,9 +317,16 @@ final class MenuBandPopoverViewController: NSViewController { aboutCol.orientation = .vertical aboutCol.alignment = .leading aboutCol.spacing = 4 - let aboutTitle = NSTextField(labelWithString: "About") - aboutTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) + // Brand identity now lives here instead of at the top of the + // popover — Menu Band heading + subtitle + about body + links all + // collapse into one section. + let aboutTitle = NSTextField(labelWithString: "Menu Band") + aboutTitle.font = NSFont.systemFont(ofSize: 13, weight: .bold) aboutTitle.textColor = .labelColor + let aboutSubtitle = NSTextField(labelWithString: + "Built-in macOS instruments, in the menu bar.") + aboutSubtitle.font = NSFont.systemFont(ofSize: 10.5) + aboutSubtitle.textColor = .secondaryLabelColor let aboutBody = NSTextField(wrappingLabelWithString: "A political project to bring the built-in macOS instruments — " + "the ones GarageBand uses — into the menu bar. Free + open source.") @@ -344,7 +341,7 @@ final class MenuBandPopoverViewController: NSViewController { linksRow.orientation = .horizontal linksRow.alignment = .centerY linksRow.spacing = 6 - let acLink = NSButton(title: "ac", + let acLink = NSButton(title: "aesthetic.computer", target: self, action: #selector(openAesthetic)) acLink.bezelStyle = .recessed acLink.controlSize = .small @@ -421,7 +418,10 @@ final class MenuBandPopoverViewController: NSViewController { // the instrument map is added/removed/resized. root.layoutSubtreeIfNeeded() let fitting = stack.fittingSize - preferredContentSize = NSSize(width: max(280, fitting.width), + // Width tracks the actual fitting size — the instrument map's + // 224 px sets the floor, so the popover is as skinny as the + // contents allow. + preferredContentSize = NSSize(width: fitting.width, height: fitting.height) } diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index 331c3c8513..509e6b527f 100644 --- a/system/public/menuband/index.html +++ b/system/public/menuband/index.html @@ -879,7 +879,7 @@

Built-in macOS instruments, in the menu bar.

- + Download 0.1 · Apple Silicon · 1.1 MB