Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: fast MIDI toggle + sectioned 2-column instrument browser, no hover

Speed:
- MIDI loopback self-test now runs once per app session (first enable),
not on every toggle. Throwing the popover switch is instant — the test
exists to surface CoreMIDI hiccups at launch, no value re-running it.
- midiSwitchToggled drops the heavy syncFromController call. The switch
already reflects the user's click; no need to refresh the instrument
list scroll, update banner, crash count, etc. for one toggle.

No more hover-driven behavior:
- Input picker hover-preview gone. Clicks commit modes directly.
- Instrument hover-preview gone. Hover only nudges the row background;
clicks play.
- Removed setHoverPreview / clearHoverPreview / setInstrumentPreview /
isHoveringTypingMode / previewPlayKey / previewTypeMode /
previewKeymap from the controller, plus effective* getters.
AppDelegate.updateIcon reads `keymap` and `typeMode` directly.
- Dropped the popover's local NSEvent monitor + viewDidAppear /
viewWillDisappear lifecycle hooks (they only existed to feed the
hover-mode demo keystrokes).

Click always plays the instrument:
- New auditionCurrentProgram() on the controller plays middle-C through
the local synth for ~600 ms regardless of MIDI mode. handleInstrument
Commit calls it after committing setMelodicProgram, so the user
*always* hears their pick — even when MIDI mode is on (which used to
silence the local synth and route only to the DAW).

Sectioned 2-column instrument browser:
- InstrumentListView rewritten as a 2-column sectioned browser. Left
column carries GM families 0..7 (Piano … Brass); right carries 8..15
(Reed … Sound FX). Each family has a colored header + 8 program rows
with a family-color stripe + monospaced number + truncated name.
- Width 448 px (2 × 220 columns + 8 gap), height 1392 px (8 sections
× 174 px each). Scrolls inside the same fixed-height popover window.
- onCommit is the only callback exposed; no onHover surface anymore.

Re-deployed:
- DMG submission 09b3327b-3fc0-475e-9f2c-2ca0a02f22ff, stapled.
- md5 0b7b530853434c20571c1469ae05cb11. Page bumped to ?v=0b7b530.

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

+220 -251
+2 -2
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 174 174 // 2-octave Notepat layout area) — Ableton is drawn with negative 175 175 // space on the right — so the status item slot never resizes and 176 176 // the popover anchor stays put. 177 - KeyboardIconRenderer.activeKeymap = menuBand.effectiveKeymap 177 + KeyboardIconRenderer.activeKeymap = menuBand.keymap 178 178 statusItem.length = KeyboardIconRenderer.imageSize.width 179 179 button.image = KeyboardIconRenderer.image( 180 180 litNotes: menuBand.litNotes, 181 181 enabled: menuBand.midiMode, 182 - typeMode: menuBand.effectiveTypeMode, 182 + typeMode: menuBand.typeMode, 183 183 melodicProgram: menuBand.melodicProgram, 184 184 hovered: hoveredElement 185 185 )
+181 -72
slab/menuband/Sources/MenuBand/InstrumentMapView.swift
··· 1 1 import AppKit 2 2 3 - /// Scrollable named-list of all 128 General MIDI programs. Each row shows 4 - /// the program number, a family-colored stripe, and the instrument name. 5 - /// Hover plays a held middle-C preview note in that program (silent in 6 - /// MIDI mode); click commits. 3 + /// Two-column sectioned browser for the 128 General MIDI programs. Left 4 + /// column carries the first 8 GM families (Piano … Brass), right column 5 + /// carries the last 8 (Reed … Sound FX). Each family has a colored 6 + /// header + 8 instrument rows beneath it. Click a row → onCommit; the 7 + /// popover hands that to the controller, which sets the program and 8 + /// auditions middle-C through the local synth so the user hears their 9 + /// pick. 7 10 /// 8 - /// Wrap in an NSScrollView before adding to the popover so the rows scroll 9 - /// inside a fixed-height window — keeps the popover compact even though 10 - /// the full list is ~2300 px tall. 11 + /// No hover audio. Hover only nudges the row's background slightly so 12 + /// the cursor's resting target reads at a glance. 11 13 final class InstrumentListView: NSView { 12 14 static let rowHeight: CGFloat = 18 15 + static let headerHeight: CGFloat = 22 13 16 static let stripeWidth: CGFloat = 4 14 - static let totalRows = 128 17 + static let sectionPadding: CGFloat = 8 15 18 16 - static let intrinsicHeight: CGFloat = rowHeight * CGFloat(totalRows) 17 - static let preferredWidth: CGFloat = 248 19 + static let columnWidth: CGFloat = 220 20 + static let columnGap: CGFloat = 8 21 + static let totalColumns = 2 22 + static let familiesPerColumn = 8 // 16 families / 2 columns 23 + static let programsPerFamily = 8 24 + 25 + static let sectionHeight: CGFloat = 26 + headerHeight + CGFloat(programsPerFamily) * rowHeight + sectionPadding 27 + 28 + static let preferredWidth: CGFloat = 29 + CGFloat(totalColumns) * columnWidth + CGFloat(totalColumns - 1) * columnGap 30 + 31 + static let intrinsicHeight: CGFloat = CGFloat(familiesPerColumn) * sectionHeight 18 32 19 33 var selectedProgram: UInt8 = 0 { didSet { needsDisplay = true } } 20 34 private(set) var hoveredProgram: UInt8? 21 35 22 - var onHover: ((Int?) -> Void)? 36 + /// Only `onCommit` is exposed — the design dropped hover audio, so we 37 + /// don't even surface a hover callback. The view tracks hover for the 38 + /// background highlight only. 23 39 var onCommit: ((Int) -> Void)? 24 40 25 41 private var trackingArea: NSTrackingArea? 26 42 27 - /// Top-down rows: y=0 should be row 0 (Acoustic Grand Piano). 43 + /// Rows top-down (y=0 → topmost row), matching reading order. 28 44 override var isFlipped: Bool { true } 29 45 30 46 override init(frame frameRect: NSRect) { ··· 50 66 trackingArea = ta 51 67 } 52 68 53 - private func rowRect(_ program: Int) -> NSRect { 54 - NSRect(x: 0, y: CGFloat(program) * Self.rowHeight, 55 - width: bounds.width, height: Self.rowHeight) 69 + // MARK: - Layout helpers 70 + 71 + /// Origin x of the given column (0 or 1). 72 + private func columnX(_ col: Int) -> CGFloat { 73 + CGFloat(col) * (Self.columnWidth + Self.columnGap) 74 + } 75 + 76 + /// Origin y (top-down) of family index `i` within its column. 77 + private func sectionY(_ familyInColumn: Int) -> CGFloat { 78 + CGFloat(familyInColumn) * Self.sectionHeight 79 + } 80 + 81 + private func rowRect(programInFamily: Int, familyInColumn: Int, column: Int) -> NSRect { 82 + let x = columnX(column) + Self.stripeWidth 83 + let y = sectionY(familyInColumn) + Self.headerHeight + CGFloat(programInFamily) * Self.rowHeight 84 + return NSRect(x: x, y: y, 85 + width: Self.columnWidth - Self.stripeWidth, 86 + height: Self.rowHeight) 87 + } 88 + 89 + private func headerRect(familyInColumn: Int, column: Int) -> NSRect { 90 + NSRect(x: columnX(column), 91 + y: sectionY(familyInColumn), 92 + width: Self.columnWidth, 93 + height: Self.headerHeight) 56 94 } 57 95 96 + private func stripeRect(familyInColumn: Int, column: Int) -> NSRect { 97 + NSRect(x: columnX(column), 98 + y: sectionY(familyInColumn) + Self.headerHeight, 99 + width: Self.stripeWidth, 100 + height: CGFloat(Self.programsPerFamily) * Self.rowHeight) 101 + } 102 + 103 + /// Given a point in the view, return the program (0..127) under it, 104 + /// or nil if the point falls in a header / column gap / padding area. 58 105 private func program(at point: NSPoint) -> Int? { 59 - let row = Int(point.y / Self.rowHeight) 60 - guard row >= 0, row < Self.totalRows else { return nil } 61 - guard point.x >= 0, point.x <= bounds.width else { return nil } 62 - return row 106 + // Column. 107 + let stride = Self.columnWidth + Self.columnGap 108 + let col = Int(point.x / stride) 109 + guard col >= 0, col < Self.totalColumns else { return nil } 110 + let xInColumn = point.x - CGFloat(col) * stride 111 + guard xInColumn >= 0, xInColumn <= Self.columnWidth else { return nil } 112 + 113 + // Family within column. 114 + let familyInCol = Int(point.y / Self.sectionHeight) 115 + guard familyInCol >= 0, familyInCol < Self.familiesPerColumn else { return nil } 116 + let yInSection = point.y - CGFloat(familyInCol) * Self.sectionHeight 117 + 118 + // Skip headers + bottom padding. 119 + if yInSection < Self.headerHeight { return nil } 120 + let yInBody = yInSection - Self.headerHeight 121 + let progInFam = Int(yInBody / Self.rowHeight) 122 + guard progInFam >= 0, progInFam < Self.programsPerFamily else { return nil } 123 + 124 + let familyAbs = col * Self.familiesPerColumn + familyInCol 125 + return familyAbs * Self.programsPerFamily + progInFam 63 126 } 64 127 65 - private func familyIndex(forProgram p: Int) -> Int { p / 8 } 128 + // MARK: - Colors 66 129 67 - private func familyColor(_ idx: Int) -> NSColor { 68 - NSColor(hue: CGFloat(idx) / 16.0, 130 + private func familyColor(_ familyAbs: Int) -> NSColor { 131 + NSColor(hue: CGFloat(familyAbs) / CGFloat(GeneralMIDI.families.count), 69 132 saturation: 0.55, brightness: 0.85, alpha: 1.0) 70 133 } 71 134 135 + // MARK: - Drawing 136 + 137 + private static let headerAttrs: [NSAttributedString.Key: Any] = [ 138 + .font: NSFont.systemFont(ofSize: 10, weight: .bold), 139 + .foregroundColor: NSColor.labelColor, 140 + .kern: 0.6, 141 + ] 72 142 private static let numberAttrs: [NSAttributedString.Key: Any] = [ 73 143 .font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular), 74 - .foregroundColor: NSColor.secondaryLabelColor, 144 + .foregroundColor: NSColor.tertiaryLabelColor, 75 145 ] 76 146 private static let nameAttrs: [NSAttributedString.Key: Any] = [ 77 147 .font: NSFont.systemFont(ofSize: 11, weight: .regular), ··· 85 155 override func draw(_ dirtyRect: NSRect) { 86 156 super.draw(dirtyRect) 87 157 88 - // Only draw rows in dirtyRect for scroll perf. The dirty rect can 89 - // sit fully outside the row band (clipped scroll, animator easing 90 - // beyond bounds) — when that happens lastRow falls below firstRow 91 - // and a closed range crashes. Guard for that. 92 - let firstRow = max(0, Int(dirtyRect.minY / Self.rowHeight)) 93 - let lastRow = min(Self.totalRows - 1, Int(dirtyRect.maxY / Self.rowHeight)) 94 - guard firstRow <= lastRow else { return } 158 + for col in 0..<Self.totalColumns { 159 + for familyInCol in 0..<Self.familiesPerColumn { 160 + let familyAbs = col * Self.familiesPerColumn + familyInCol 161 + guard familyAbs < GeneralMIDI.families.count else { continue } 162 + let (familyName, range) = GeneralMIDI.families[familyAbs] 163 + let color = familyColor(familyAbs) 164 + 165 + // Header. 166 + let hRect = headerRect(familyInColumn: familyInCol, column: col) 167 + if hRect.intersects(dirtyRect) { 168 + color.withAlphaComponent(0.18).setFill() 169 + NSBezierPath(rect: hRect).fill() 170 + let titleStr = NSString(string: familyName.uppercased()) 171 + titleStr.draw(at: NSPoint(x: hRect.minX + 8, y: hRect.minY + 5), 172 + withAttributes: Self.headerAttrs) 173 + } 95 174 96 - for p in firstRow...lastRow { 97 - let r = rowRect(p) 175 + // Family color stripe spanning the body. 176 + let sRect = stripeRect(familyInColumn: familyInCol, column: col) 177 + if sRect.intersects(dirtyRect) { 178 + color.setFill() 179 + NSBezierPath(rect: sRect).fill() 180 + } 98 181 99 - // Hover/selected backgrounds. 100 - if hoveredProgram == UInt8(p) { 101 - NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() 102 - NSBezierPath(rect: r).fill() 103 - } else if selectedProgram == UInt8(p) { 104 - NSColor.controlAccentColor.withAlphaComponent(0.10).setFill() 105 - NSBezierPath(rect: r).fill() 106 - } else if p % 2 == 0 { 107 - // Subtle alternating zebra so the list scans easily. 108 - NSColor.labelColor.withAlphaComponent(0.025).setFill() 109 - NSBezierPath(rect: r).fill() 110 - } 182 + // Programs. 183 + for progInFam in 0..<Self.programsPerFamily { 184 + let p = range.lowerBound + progInFam 185 + guard p < GeneralMIDI.programNames.count else { continue } 186 + let r = rowRect(programInFamily: progInFam, 187 + familyInColumn: familyInCol, 188 + column: col) 189 + guard r.intersects(dirtyRect) else { continue } 111 190 112 - // Family-colored stripe on the left. 113 - let famIdx = familyIndex(forProgram: p) 114 - let stripeRect = NSRect(x: 0, y: r.minY + 2, 115 - width: Self.stripeWidth, height: r.height - 4) 116 - familyColor(famIdx).setFill() 117 - NSBezierPath(rect: stripeRect).fill() 191 + if hoveredProgram == UInt8(p) { 192 + NSColor.controlAccentColor.withAlphaComponent(0.20).setFill() 193 + NSBezierPath(rect: r).fill() 194 + } else if selectedProgram == UInt8(p) { 195 + NSColor.controlAccentColor.withAlphaComponent(0.12).setFill() 196 + NSBezierPath(rect: r).fill() 197 + } else if progInFam % 2 == 0 { 198 + NSColor.labelColor.withAlphaComponent(0.025).setFill() 199 + NSBezierPath(rect: r).fill() 200 + } 118 201 119 - // Program number (000–127), monospaced for clean alignment. 120 - let number = NSString(format: "%03d", p) 121 - number.draw(at: NSPoint(x: Self.stripeWidth + 6, y: r.minY + 3), 122 - withAttributes: Self.numberAttrs) 202 + // Program number — three monospace digits, dim. 203 + let num = NSString(format: "%03d", p) 204 + num.draw(at: NSPoint(x: r.minX + 4, y: r.minY + 3), 205 + withAttributes: Self.numberAttrs) 123 206 124 - // Name. 125 - let name = GeneralMIDI.programNames[p] as NSString 126 - let nameAttrs = (selectedProgram == UInt8(p)) 127 - ? Self.nameAttrsSelected 128 - : Self.nameAttrs 129 - name.draw(at: NSPoint(x: Self.stripeWidth + 36, y: r.minY + 2), 130 - withAttributes: nameAttrs) 207 + // Name — truncated to fit column width. 208 + let nameAttrs = (selectedProgram == UInt8(p)) 209 + ? Self.nameAttrsSelected 210 + : Self.nameAttrs 211 + let name = GeneralMIDI.programNames[p] 212 + let nameOriginX = r.minX + 32 213 + let nameMaxWidth = r.maxX - nameOriginX - 4 214 + drawTruncated(name, at: NSPoint(x: nameOriginX, y: r.minY + 2), 215 + maxWidth: nameMaxWidth, attrs: nameAttrs) 216 + } 217 + } 218 + } 219 + } 220 + 221 + private func drawTruncated(_ text: String, at point: NSPoint, 222 + maxWidth: CGFloat, 223 + attrs: [NSAttributedString.Key: Any]) { 224 + var s = text 225 + var attr = NSAttributedString(string: s, attributes: attrs) 226 + if attr.size().width <= maxWidth { 227 + attr.draw(at: point) 228 + return 229 + } 230 + // Tail-truncate. Strip until it fits, then append ellipsis. 231 + let ellipsis = "…" 232 + while !s.isEmpty { 233 + s.removeLast() 234 + attr = NSAttributedString(string: s + ellipsis, attributes: attrs) 235 + if attr.size().width <= maxWidth { break } 131 236 } 237 + attr.draw(at: point) 132 238 } 239 + 240 + // MARK: - Mouse 133 241 134 242 override func mouseEntered(with event: NSEvent) { 135 243 updateHover(at: convert(event.locationInWindow, from: nil)) ··· 138 246 updateHover(at: convert(event.locationInWindow, from: nil)) 139 247 } 140 248 override func mouseExited(with event: NSEvent) { 141 - if let prev = hoveredProgram { 249 + if hoveredProgram != nil { 142 250 hoveredProgram = nil 143 - setNeedsDisplay(rowRect(Int(prev))) 144 - onHover?(nil) 251 + needsDisplay = true 145 252 } 146 253 } 147 254 148 255 private func updateHover(at point: NSPoint) { 149 256 let p = program(at: point).map { UInt8($0) } 150 257 if p != hoveredProgram { 151 - let prev = hoveredProgram 152 258 hoveredProgram = p 153 - if let prev = prev { setNeedsDisplay(rowRect(Int(prev))) } 154 - if let p = p { setNeedsDisplay(rowRect(Int(p))) } 155 - onHover?(p.map { Int($0) }) 259 + needsDisplay = true 156 260 } 157 261 } 158 262 ··· 162 266 } 163 267 } 164 268 165 - /// Scroll so the row for `program` is visible. Called after commit so 166 - /// the user's selection re-anchors at view center. 269 + /// Scroll so the row for `program` is visible. 167 270 func scrollProgramIntoView(_ program: UInt8, animated: Bool = false) { 168 - let r = rowRect(Int(program)) 271 + let prog = Int(program) 272 + let familyAbs = prog / Self.programsPerFamily 273 + let progInFam = prog % Self.programsPerFamily 274 + let col = familyAbs / Self.familiesPerColumn 275 + let familyInCol = familyAbs % Self.familiesPerColumn 276 + let r = rowRect(programInFamily: progInFam, 277 + familyInColumn: familyInCol, 278 + column: col) 169 279 guard let scroll = enclosingScrollView else { return } 170 280 let visible = scroll.documentVisibleRect 171 281 if !visible.contains(r) { 172 - // Center it if possible. 173 282 let centerY = r.midY - visible.height / 2 174 283 let clampedY = max(0, min(bounds.height - visible.height, centerY)) 175 284 let newOrigin = NSPoint(x: 0, y: clampedY)
+23 -91
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 31 31 UserDefaults.standard.bool(forKey: typeModeKey) 32 32 } 33 33 34 - // Hover-preview overlay. The popover's input segmented control sets these 35 - // when the user hovers a segment so the menubar piano can render that 36 - // mode's range/labels and the local key monitor can play notes through 37 - // its keymap — all without committing to UserDefaults. Cleared on hover 38 - // exit. Effective getters fall through to the real values when nil. 39 - private var previewTypeMode: Bool? 40 - private var previewKeymap: Keymap? 41 - 42 - var effectiveTypeMode: Bool { previewTypeMode ?? typeMode } 43 - var effectiveKeymap: Keymap { previewKeymap ?? keymap } 44 - 45 - /// True only while the popover is hovering Notepat or Ableton — i.e. a 46 - /// preview is active AND it's a typing mode (not Pointer). Used to gate 47 - /// the popover's local key monitor so demo keystrokes are consumed 48 - /// only when actually previewing. 49 - var isHoveringTypingMode: Bool { previewTypeMode == true } 50 - 51 - func setHoverPreview(typeMode tm: Bool, keymap km: Keymap) { 52 - previewTypeMode = tm 53 - previewKeymap = km 54 - onChange?() 55 - } 56 - 57 - func clearHoverPreview() { 58 - guard previewTypeMode != nil || previewKeymap != nil else { return } 59 - previewTypeMode = nil 60 - previewKeymap = nil 61 - onChange?() 62 - } 63 - 64 - /// Local-key demo while the popover is hovering an input mode. Maps the 65 - /// keystroke through the preview (or real) keymap and feeds it through 66 - /// `startTapNote`/`stopTapNote` so it sounds + lights up + sends MIDI 67 - /// just like a real tap. 68 - func previewPlayKey(keyCode: UInt16, isDown: Bool) { 69 - let km = previewKeymap ?? keymap 70 - guard let note = MenuBandLayout.midiNote(forKeyCode: keyCode, 71 - octaveShift: octaveShift, 72 - keymap: km) else { return } 73 - if isDown { 74 - startTapNote(note) 75 - } else { 76 - stopTapNote(note) 34 + /// Audition the currently-loaded melodic program through the local 35 + /// synth, regardless of MIDI mode. Used by the instrument-list click 36 + /// handler so the user *always* hears their instrument pick, even when 37 + /// MIDI is on (which normally silences the local synth and routes to 38 + /// the DAW). Plays middle-C for ~600 ms then releases. 39 + func auditionCurrentProgram() { 40 + let note: UInt8 = 60 41 + synth.noteOn(note, velocity: 90, channel: 0) 42 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in 43 + self?.synth.noteOff(note, channel: 0) 77 44 } 78 45 } 79 46 ··· 109 76 func setMelodicProgram(_ program: UInt8) { 110 77 UserDefaults.standard.set(Int(program), forKey: melodicProgramKey) 111 78 synth.setMelodicProgram(program) 112 - // Any in-flight preview is no longer relevant — drop its held note + 113 - // saved-program so the next hover restarts cleanly. 114 - if let prev = previewNote { 115 - synth.noteOff(prev) 116 - previewNote = nil 117 - } 118 - previewSavedProgram = nil 119 79 onChange?() 120 80 } 121 81 122 - // MARK: - Instrument hover preview (popover flat-map) 123 - 124 - private var previewSavedProgram: UInt8? 125 - private var previewNote: UInt8? 126 - private let previewVelocity: UInt8 = 80 127 - 128 - /// Hover-preview a program in the instrument flat-map. Pass nil to stop 129 - /// preview and restore the user's committed program. Plays a held 130 - /// middle-C note in the previewed program through the local synth so 131 - /// the user can audition without committing. Silent when MIDI mode is 132 - /// on (DAW is in charge of audio). 133 - func setInstrumentPreview(_ program: UInt8?) { 134 - // Stop any previously playing preview note immediately. 135 - if let prev = previewNote { 136 - synth.noteOff(prev) 137 - previewNote = nil 138 - } 139 - 140 - guard let prog = program else { 141 - // Hover ended — restore the user's committed program. 142 - if let saved = previewSavedProgram { 143 - synth.setMelodicProgram(saved) 144 - previewSavedProgram = nil 145 - } 146 - return 147 - } 148 - 149 - // Save the user's committed program once so we can restore on exit. 150 - if previewSavedProgram == nil { 151 - previewSavedProgram = melodicProgram 152 - } 153 - 154 - synth.setMelodicProgram(prog) 155 - // Don't add audio in MIDI mode — DAW is hearing user input. 156 - guard !midiMode else { return } 157 - let note: UInt8 = 60 158 - synth.noteOn(note, velocity: previewVelocity, channel: 0) 159 - previewNote = note 160 - } 161 - 162 82 163 83 func bootstrap() { 164 84 // Built-in synth is always live. TYPE mode and MIDI mode are now ··· 206 126 synth.panic() // DAW takes over from internal synth 207 127 UserDefaults.standard.set(true, forKey: midiModeKey) 208 128 onChange?() 209 - runMIDILoopbackTest() 129 + // Self-test runs once per session — first enable. Skipping it on 130 + // every toggle removes the 50–800ms perceived lag from the popover 131 + // switch. 132 + if !loopbackTestRunOnce { 133 + loopbackTestRunOnce = true 134 + runMIDILoopbackTest() 135 + } 210 136 } 211 137 212 138 // Last loopback test result, surfaced to the popover as a status line. ··· 218 144 } 219 145 private(set) var midiSelfTest: MIDISelfTest = .unknown 220 146 var onSelfTestChanged: (() -> Void)? 147 + 148 + /// `true` once the loopback self-test has run successfully for this app 149 + /// session. We skip subsequent runs on toggle so the switch flips 150 + /// instantly — the test exists to surface CoreMIDI hiccups, and once 151 + /// we've confirmed the port works there's no value in re-checking. 152 + private var loopbackTestRunOnce = false 221 153 222 154 /// Sends a single test note out the virtual port and listens on the 223 155 /// process's own input port for it to come back. If it loops back within
+13 -85
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 69 69 private var crashSendButton: NSButton! 70 70 private var updateBanner: NSView! 71 71 private var updateLabel: NSTextField! 72 - private var keyMonitor: Any? 73 72 74 73 override func loadView() { 75 74 // Plain solid-color background — no NSVisualEffectView. The visual ··· 157 156 action: #selector(inputModeChanged(_:)) 158 157 ) 159 158 inputSegmented.translatesAutoresizingMaskIntoConstraints = false 160 - inputSegmented.onHoverChange = { [weak self] seg in 161 - self?.handleInputHover(segment: seg) 162 - } 159 + // No hover preview — clicks commit the mode directly. 163 160 stack.addArrangedSubview(inputSegmented) 164 161 inputSegmented.widthAnchor.constraint(equalToConstant: 248).isActive = true 165 162 166 163 let inputHint = NSTextField(labelWithString: 167 - "Hover to preview · ⌃⌥⌘P toggles last keystrokes mode") 164 + "⌃⌥⌘P toggles last keystrokes mode") 168 165 inputHint.font = NSFont.systemFont(ofSize: 10) 169 166 inputHint.textColor = .secondaryLabelColor 170 167 stack.addArrangedSubview(inputHint) ··· 202 199 stack.addArrangedSubview(instrumentLabel) 203 200 204 201 instrumentList = InstrumentListView() 205 - instrumentList.onHover = { [weak self] prog in 206 - self?.handleInstrumentHover(prog) 207 - } 208 202 instrumentList.onCommit = { [weak self] prog in 209 203 self?.handleInstrumentCommit(prog) 210 204 } ··· 513 507 // MARK: - Actions 514 508 515 509 @objc private func midiSwitchToggled(_ sender: NSSwitch) { 510 + // Just toggle — don't run the heavy syncFromController. The switch 511 + // already shows the user's intent; the loopback test (skipped on 512 + // toggles after the first per session) and other panels don't need 513 + // to refresh. 516 514 menuBand?.toggleMIDIMode() 517 - syncFromController() 518 515 } 519 516 520 517 /// 0 = Pointer, 1 = Notepat, 2 = Ableton. Matches the segmented control ··· 526 523 527 524 @objc private func inputModeChanged(_ sender: NSSegmentedControl) { 528 525 guard let m = menuBand else { return } 529 - // Clicking commits — clear any in-flight hover preview first so the 530 - // controller's effective state isn't shadowed by a stale overlay. 531 - m.clearHoverPreview() 532 526 switch sender.selectedSegment { 533 527 case 0: // Pointer 534 528 if m.typeMode { m.toggleTypeMode() } 535 - // Pointer mode keeps the existing keymap so the piano range stays 536 - // wherever the user last set it (default .notepat = 2 octaves). 537 - case 1: // Notepat 529 + case 1: // Notepat.com 538 530 m.keymap = .notepat 539 531 if !m.typeMode { m.toggleTypeMode() } 540 532 case 2: // Ableton ··· 542 534 if !m.typeMode { m.toggleTypeMode() } 543 535 default: break 544 536 } 545 - syncFromController() 546 - } 547 - 548 - private func handleInputHover(segment: Int?) { 549 - guard let m = menuBand else { return } 550 - guard let seg = segment else { 551 - m.clearHoverPreview() 552 - return 553 - } 554 - switch seg { 555 - case 0: // Pointer — preview as no-typeMode, keep current keymap range 556 - m.setHoverPreview(typeMode: false, keymap: m.keymap) 557 - case 1: 558 - m.setHoverPreview(typeMode: true, keymap: .notepat) 559 - case 2: 560 - m.setHoverPreview(typeMode: true, keymap: .ableton) 561 - default: break 562 - } 563 - } 564 - 565 - private func handleInstrumentHover(_ program: Int?) { 566 - // Forward to controller — handles preview state machine + audio. 567 - // Pass UInt8 or nil to clear preview. 568 - if let p = program { 569 - menuBand?.setInstrumentPreview(UInt8(p)) 570 - } else { 571 - menuBand?.setInstrumentPreview(nil) 572 - } 537 + // No syncFromController — segmented control already reflects the 538 + // user's click and the rest of the popover doesn't need to refresh. 573 539 } 574 540 575 541 private func handleInstrumentCommit(_ program: Int) { 576 542 guard let m = menuBand else { return } 577 543 m.setMelodicProgram(UInt8(program)) 578 544 instrumentList.selectedProgram = UInt8(program) 579 - // Sound the freshly-picked instrument out — a half-second middle-C 580 - // confirmation note in the new program. Lets the user actually hear 581 - // their commit instead of just seeing a row highlight. 582 - let note: UInt8 = 60 583 - m.startTapNote(note, velocity: 90, pan: 64) 584 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak m] in 585 - m?.stopTapNote(note) 586 - } 545 + // Always audition through the local synth — even when MIDI mode is 546 + // on (which normally silences the synth). The user wants to hear 547 + // the instrument they picked, period. 548 + m.auditionCurrentProgram() 587 549 } 588 550 589 551 @objc private func octaveChanged(_ sender: NSStepper) { ··· 595 557 menuBand?.octaveShift = 0 596 558 octaveStepper.integerValue = 0 597 559 updateOctaveLabel(0) 598 - } 599 - 600 - // MARK: - Local key demo (only fires while hovering an input segment) 601 - 602 - override func viewDidAppear() { 603 - super.viewDidAppear() 604 - // Local monitor: catches keystrokes while the popover window is key. 605 - // We only consume them when a hover preview is active, so popover 606 - // navigation (Esc, arrows on focused controls) keeps working when 607 - // no segment is hovered. 608 - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 609 - guard let self = self, let m = self.menuBand else { return event } 610 - // Modifier combos pass through (Cmd-Q etc still work). 611 - let mods: NSEvent.ModifierFlags = [.command, .control, .option] 612 - if !event.modifierFlags.intersection(mods).isEmpty { return event } 613 - // Only consume while hover-previewing Notepat or Ableton. 614 - guard m.isHoveringTypingMode else { return event } 615 - if event.isARepeat { return nil } 616 - m.previewPlayKey(keyCode: event.keyCode, isDown: event.type == .keyDown) 617 - return nil 618 - } 619 - } 620 - 621 - override func viewWillDisappear() { 622 - super.viewWillDisappear() 623 - if let m = keyMonitor { 624 - NSEvent.removeMonitor(m) 625 - keyMonitor = nil 626 - } 627 - // Ensure hover previews don't survive popover dismissal — clear 628 - // both the input-mode preview and the instrument preview, otherwise 629 - // a held preview note can keep ringing after the popover closes. 630 - menuBand?.clearHoverPreview() 631 - menuBand?.setInstrumentPreview(nil) 632 560 } 633 561 634 562 @objc private func openAesthetic() {
+1 -1
system/public/menuband/index.html
··· 556 556 <p class="tagline">Built-in macOS instruments, in the menu bar.</p> 557 557 558 558 <div class="button-row"> 559 - <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=2b9d32b" download> 559 + <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=0b7b530" download> 560 560 Download 561 561 <small>0.1 · Apple Silicon · 1.1 MB</small> 562 562 </a>