diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index bac5e556bb..2fdc435cc9 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -272,15 +272,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSSound(named: NSSound.Name("Tink"))?.play() showPopover() return - case .drum(let drumNote): - // One-shot trigger — drums don't track-drag like piano keys. - // 100 ms hold then release is plenty for the GM drum samples - // to develop their attack. - menuBand.startTapNote(drumNote, velocity: 110, pan: 64) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak menuBand] in - menuBand?.stopTapNote(drumNote) - } - return case .note(let n): startNote = n case .none: diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index dadad5493e..d427730b41 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -153,8 +153,38 @@ final class InstrumentListView: NSView { } } + // mouseDown → mouseDragged → mouseUp lets the user click-drag across + // cells to sonically browse: every cell crossed during the drag fires + // onHover (preview note); release commits the cell under the cursor. + private var dragging = false + override func mouseDown(with event: NSEvent) { - if let p = program(at: convert(event.locationInWindow, from: nil)) { + dragging = true + let pt = convert(event.locationInWindow, from: nil) + if let p = program(at: pt) { + // Treat the press itself as a hover-into-this-cell so the + // preview note starts immediately on click, not only on + // dragging away. + if hoveredProgram != UInt8(p) { + let prev = hoveredProgram + hoveredProgram = UInt8(p) + if let prev = prev { setNeedsDisplay(cellRect(program: Int(prev))) } + setNeedsDisplay(cellRect(program: p)) + } + onHover?(p) + } + } + + override func mouseDragged(with event: NSEvent) { + guard dragging else { return } + updateHover(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseUp(with event: NSEvent) { + guard dragging else { return } + dragging = false + let pt = convert(event.locationInWindow, from: nil) + if let p = program(at: pt) { onCommit?(p) } } diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index 6864899351..3c6f325d01 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -86,31 +86,6 @@ enum KeyboardIconRenderer { // tall enough to drag across easily static let pad: CGFloat = 0.5 - // Drum sample pads on the left of the piano — Akai MPC style: rounded - // squarish pads with a subtle gradient + colored center, sized to - // match white-key width so the row reads as one cohesive instrument. - enum DrumPad: Int, CaseIterable { - case kick = 36 // GM Bass Drum 1 - case snare = 38 // GM Acoustic Snare - case closedHat = 42 // GM Closed Hi-Hat - - var midiNote: UInt8 { UInt8(rawValue) } - - /// Pad accent color — warm Akai orange→pink for kick/snare, cooler - /// gold for hi-hat to differentiate at a glance. - var color: NSColor { - switch self { - case .kick: return NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.20, alpha: 1.0) - case .snare: return NSColor(calibratedRed: 1.00, green: 0.30, blue: 0.50, alpha: 1.0) - case .closedHat: return NSColor(calibratedRed: 1.00, green: 0.78, blue: 0.20, alpha: 1.0) - } - } - } - static let drumPads: [DrumPad] = [.kick, .snare, .closedHat] - static let drumW: CGFloat = whiteW // match white-key width - static let drumH: CGFloat = whiteH - static let drumPianoGap: CGFloat = 5.0 // breathing room between pads + piano - // Settings — simple monochrome music note that reads like a native // status-bar icon. Click → popup menu with TYPE / MIDI / Instrument / // About. @@ -121,7 +96,6 @@ enum KeyboardIconRenderer { enum HitResult: Equatable { case openSettings case note(UInt8) - case drum(UInt8) } // Letter labels keyed by MIDI note, per layout. The renderer picks @@ -167,31 +141,13 @@ enum KeyboardIconRenderer { CGFloat(whiteList().count) * whiteW } - /// Width of the drum pad strip — zero in compact display layout - /// (no piano keys, just the chip), full in any layout that draws keys. - private static var drumsAreaWidth: CGFloat { - displayLayout == .compact ? 0 : CGFloat(drumPads.count) * drumW - } - private static var drumsTrailingGap: CGFloat { - drumsAreaWidth > 0 ? drumPianoGap : 0 - } - static var imageSize: NSSize { - let totalW = ceil(pad + drumsAreaWidth + drumsTrailingGap - + pianoWidth + settingsGap + settingsW + pad) + let totalW = ceil(pad + pianoWidth + settingsGap + settingsW + pad) let totalH = ceil(whiteH + pad * 2) return NSSize(width: totalW, height: totalH) } - private static var drumsOriginX: CGFloat { pad } - private static var pianoOriginX: CGFloat { - pad + drumsAreaWidth + drumsTrailingGap - } - - private static func drumRect(at idx: Int) -> NSRect { - let x = drumsOriginX + CGFloat(idx) * drumW - return NSRect(x: x, y: pad, width: drumW, height: drumH) - } + private static var pianoOriginX: CGFloat { pad } /// Settings chip's visual rect IS its hit-test rect — they're identical /// so the user gets visual feedback wherever the click lands. @@ -289,17 +245,6 @@ enum KeyboardIconRenderer { } NSGraphicsContext.restoreGraphicsState() - // Drum sample pads — Akai MPC style. Drawn after piano so any - // overlap (none expected, but cheap insurance) sits on top. - if drumsAreaWidth > 0 { - for (idx, pad) in drumPads.enumerated() { - let r = drumRect(at: idx) - let isLit = litNotes.contains(pad.midiNote) - let isHover = hovered == .drum(pad.midiNote) - drawDrumPad(in: r, pad: pad, lit: isLit, hovered: isHover) - } - } - // Single settings chip — glyph + color reflect MIDI/DAW state. // MIDI on → `waveform` tinted accent (signal flowing to DAW); // MIDI off → `slider.horizontal.3` in label color (generic). @@ -323,81 +268,10 @@ enum KeyboardIconRenderer { return NSRect(x: leftX, y: 0, width: rightX - leftX, height: imageSize.height) } - // MARK: - Drum pad drawing - - /// Akai MPC sample-pad look: rounded square, vertical gradient (top - /// brighter → bottom darker), thin border, centered glyph. Lit state - /// flashes the pad accent color full-strength + bright glyph. - private static func drawDrumPad(in rect: NSRect, pad: DrumPad, - lit: Bool, hovered: Bool) { - let inset = rect.insetBy(dx: 1.5, dy: 2.0) - let path = NSBezierPath(roundedRect: inset, xRadius: 3, yRadius: 3) - - let baseColor = pad.color - // Hover/lit shift the pad lightness so feedback reads instantly. - let topColor: NSColor - let botColor: NSColor - if lit { - topColor = baseColor.highlight(withLevel: 0.35) ?? baseColor - botColor = baseColor - } else if hovered { - topColor = baseColor.highlight(withLevel: 0.15) ?? baseColor - botColor = baseColor.shadow(withLevel: 0.10) ?? baseColor - } else { - // Resting: darker so lit feels brighter by contrast. - topColor = baseColor.shadow(withLevel: 0.18) ?? baseColor - botColor = baseColor.shadow(withLevel: 0.45) ?? baseColor - } - NSGradient(starting: topColor, ending: botColor)?.draw(in: path, angle: -90) - - // Inner top sheen — 1 px highlight stripe at the top of the pad - // gives the MPC "lit from above" feel. - let sheen = NSBezierPath(roundedRect: NSRect(x: inset.minX + 0.5, - y: inset.maxY - 1.5, - width: inset.width - 1, - height: 1), - xRadius: 0.5, yRadius: 0.5) - NSColor.white.withAlphaComponent(lit ? 0.55 : 0.30).setFill() - sheen.fill() - - // Border. - NSColor.black.withAlphaComponent(0.45).setStroke() - path.lineWidth = 0.5 - path.stroke() - - // Glyph — single-letter abbreviation in monospaced bold, centered. - let letter: String - switch pad { - case .kick: letter = "K" - case .snare: letter = "S" - case .closedHat: letter = "H" - } - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: 9, weight: .heavy), - .foregroundColor: lit - ? NSColor.white - : NSColor.white.withAlphaComponent(0.85), - ] - let str = NSAttributedString(string: letter, attributes: attrs) - let size = str.size() - str.draw(at: NSPoint(x: rect.midX - size.width / 2, - y: rect.midY - size.height / 2 - 0.5)) - } - // MARK: - Hit testing static func hit(at point: NSPoint) -> HitResult? { if settingsHitRect.contains(point) { return .openSettings } - // Drum pads — checked before piano so the area to the left of the - // keyboard maps to drum hits, not falling through to piano edge - // tolerance. - if drumsAreaWidth > 0 { - for (idx, pad) in drumPads.enumerated() { - if drumRect(at: idx).contains(point) { - return .drum(pad.midiNote) - } - } - } let whites = whiteList() var whiteIndex: [Int: Int] = [:] for (i, m) in whites.enumerated() { whiteIndex[m] = i } diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index 756920b7cd..fd6498698c 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -83,13 +83,13 @@ final class MenuBandPopoverViewController: NSViewController { root.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor root.translatesAutoresizingMaskIntoConstraints = false - // Vertical stack of rows. Tight spacing + smaller edge insets so - // the popover doesn't carry a lot of negative space. + // Vertical stack of rows. Tight spacing + small edge insets so the + // popover hugs the 224 px instrument grid with no slack. let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading stack.spacing = 6 - stack.edgeInsets = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) + stack.edgeInsets = NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) stack.translatesAutoresizingMaskIntoConstraints = false root.addSubview(stack) @@ -178,7 +178,7 @@ final class MenuBandPopoverViewController: NSViewController { stack.addArrangedSubview(titleRow) titleRow.widthAnchor.constraint(equalTo: stack.widthAnchor, - constant: -24).isActive = true + constant: -16).isActive = true // Update banner — hidden until UpdateChecker reports a newer // release. Tinted accent so the user notices it without it feeling @@ -401,7 +401,7 @@ final class MenuBandPopoverViewController: NSViewController { quitRow.addArrangedSubview(quit) stack.addArrangedSubview(quitRow) quitRow.widthAnchor.constraint(equalTo: stack.widthAnchor, - constant: -24).isActive = true + constant: -16).isActive = true NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: root.leadingAnchor), diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index f0277a561b..1effae14cc 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.