// MenuBandPopoverPanel.swift // // Custom popover-styled NSPanel. NSPopover places its arrow at the // anchor's screen-x and auto-fits the content to the visible screen, // which means the arrow always lands roughly where the anchor sits // — when the status item is near the screen edge, the popover content // shifts inward and the arrow ends up on the inward side of the // content rather than flush at one corner. This panel decouples the // two: the window's frame and the arrow tip's screen-x are set // independently, so the content can sit far from the arrow and the // arrow can land at any horizontal position on the panel's top edge. // // Visually it mimics NSPopover: rounded body + small triangular arrow // rendered through a single NSVisualEffectView with a CAShapeLayer // mask, so the liquid-glass material flows continuously from the // arrow into the body. import AppKit final class MenuBandPopoverPanel: NSPanel { static let arrowHeight: CGFloat = 11 static let arrowWidth: CGFloat = 22 static let cornerRadius: CGFloat = 10 let chrome: MenuBandPopoverChrome init(content: NSView, contentSize: NSSize) { let totalSize = NSSize( width: contentSize.width, height: contentSize.height + Self.arrowHeight ) chrome = MenuBandPopoverChrome(content: content) super.init( contentRect: NSRect(origin: .zero, size: totalSize), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) isOpaque = false backgroundColor = .clear hasShadow = true level = .popUpMenu animationBehavior = .none collectionBehavior = [.transient, .ignoresCycle] hidesOnDeactivate = false canHide = false isMovableByWindowBackground = false acceptsMouseMovedEvents = true titleVisibility = .hidden titlebarAppearsTransparent = true isReleasedWhenClosed = false contentView = chrome } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } /// Routes ⌘/⌥+letter chords to the note path WHEN the popover is the key /// window (quiet-focus with the popover open). Mirrors `KeyCapturePanel`'s /// override so a simultaneous ⌘+letter chords consistently and shadows the /// matching system command — but we give focused controls (any text field) /// first crack via `super`, so editing shortcuts there still work. var keyEquivalentHandler: ((NSEvent) -> Bool)? override func performKeyEquivalent(with event: NSEvent) -> Bool { if super.performKeyEquivalent(with: event) { return true } if let handler = keyEquivalentHandler, handler(event) { return true } return false } /// Position the panel so that: /// • the panel's top edge sits at `topScreenY` (the menubar bottom), /// • the panel's left edge sits at `leftScreenX`, /// • the arrow tip points at `arrowScreenX`. /// `arrowScreenX` may be inside or outside the panel's horizontal /// extent; the chrome clamps the rendered arrow to a small inset so /// it never falls off the rounded corner radii. func position(leftScreenX: CGFloat, topScreenY: CGFloat, arrowScreenX: CGFloat) { let frameSize = frame.size let panelFrame = NSRect( x: leftScreenX, y: topScreenY - frameSize.height, width: frameSize.width, height: frameSize.height ) setFrame(panelFrame, display: true) // Arrow position is in chrome-local coords (origin at panel // bottom-left). Convert from screen-x. let arrowLocalX = arrowScreenX - leftScreenX chrome.setArrowOffsetFromLeft(arrowLocalX) } /// Resize the whole panel to host `contentSize` (+ the arrow strip), /// keeping the TOP edge pinned to the menubar so the popover grows / /// shrinks downward — used when the notation staff slides in/out and /// the entire popover height changes, not just an inner row. func resizeContent(to contentSize: NSSize, animated: Bool) { let newH = contentSize.height + Self.arrowHeight let newW = max(contentSize.width, frame.width) NSLog("MenuBand panel resize: %.0f×%.0f → %.0f×%.0f", frame.width, frame.height, newW, newH) let top = frame.maxY // pin top edge let originX = frame.minX // keep left edge let newFrame = NSRect(x: originX, y: top - newH, width: newW, height: newH) if animated { let timing = CAMediaTimingFunction(name: .easeInEaseOut) // Animate the mask silhouette in lockstep with the frame so the // glass body grows downward (top edge pinned) and the picker // slides into view, rather than the mask snapping to full size // while the window frame catches up. chrome.animateMask(toPanelSize: newFrame.size, duration: 0.2, timing: timing) NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.2 ctx.timingFunction = timing animator().setFrame(newFrame, display: true) } } else { setFrame(newFrame, display: true) } } } final class MenuBandPopoverChrome: NSView { /// The single backdrop surface for the whole popover (body + arrow). /// On macOS 26 this is a real liquid-glass `NSGlassEffectView`; on /// older systems it falls back to the `.popover` `NSVisualEffectView`. /// Either way a `CAShapeLayer` mask carves the popover silhouette so /// the surface flows continuously from the arrow tip into the body. private let backdrop: NSView /// Non-nil only on the legacy (pre-26) path, for appearance re-tints. private let legacyVisualEffect: NSVisualEffectView? private let content: NSView private let maskLayer = CAShapeLayer() /// Rounds the content view's own corners to match the backdrop /// silhouette. Without it the masked glass backdrop is rounded but /// the content (a plain rectangle on top) shows sharp corners. private let contentMaskLayer = CAShapeLayer() private var arrowOffsetFromLeft: CGFloat = MenuBandPopoverPanel.cornerRadius + MenuBandPopoverPanel.arrowWidth / 2 init(content: NSView) { self.content = content // Prefer the dedicated liquid-glass view when the platform (and // the user's style override) allow it; otherwise the classic // vibrancy material. if PianoWaveformWindowStyle.shouldUseLiquidGlass, #available(macOS 26.0, *) { let glass = NSGlassEffectView() glass.style = .clear self.backdrop = glass self.legacyVisualEffect = nil } else { let ve = NSVisualEffectView() ve.material = .popover ve.blendingMode = .behindWindow ve.state = .active self.backdrop = ve self.legacyVisualEffect = ve } super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = false backdrop.wantsLayer = true backdrop.translatesAutoresizingMaskIntoConstraints = false addSubview(backdrop) content.translatesAutoresizingMaskIntoConstraints = false content.wantsLayer = true addSubview(content) NSLayoutConstraint.activate([ backdrop.leadingAnchor.constraint(equalTo: leadingAnchor), backdrop.trailingAnchor.constraint(equalTo: trailingAnchor), backdrop.topAnchor.constraint(equalTo: topAnchor), backdrop.bottomAnchor.constraint(equalTo: bottomAnchor), // Content sits in the body region (below the arrow strip). content.leadingAnchor.constraint(equalTo: leadingAnchor), content.trailingAnchor.constraint(equalTo: trailingAnchor), content.topAnchor.constraint( equalTo: topAnchor, constant: MenuBandPopoverPanel.arrowHeight), content.bottomAnchor.constraint(equalTo: bottomAnchor), ]) backdrop.layer?.mask = maskLayer content.layer?.mask = contentMaskLayer } /// Force the legacy NSVisualEffectView to re-tint against the /// current effective appearance. AppDelegate calls this from /// `systemAppearanceChanged()` because NSVisualEffectView can latch /// on to the appearance present at construction time — without this /// nudge, the popover's "popover" material keeps showing the old /// tint after a system flip. The liquid-glass view re-tints itself, /// so this is a no-op on the macOS 26 path. func refreshAppearance() { guard let ve = legacyVisualEffect else { needsDisplay = true; return } ve.appearance = nil // Toggling the state forces AppKit to rebuild the material's // CALayer backing, picking up the new effectiveAppearance. ve.state = .inactive ve.state = .active ve.needsDisplay = true needsDisplay = true } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() refreshAppearance() } required init?(coder: NSCoder) { fatalError() } override var isFlipped: Bool { false } func setArrowOffsetFromLeft(_ offset: CGFloat) { // Clamp so the arrow fits between the rounded body corners. let minX = MenuBandPopoverPanel.cornerRadius + MenuBandPopoverPanel.arrowWidth / 2 + 2 let maxX = bounds.width - MenuBandPopoverPanel.cornerRadius - MenuBandPopoverPanel.arrowWidth / 2 - 2 arrowOffsetFromLeft = max(minX, min(maxX, offset)) rebuildMask() } override func layout() { super.layout() rebuildMask() } private func rebuildMask() { let size = bounds.size guard size.width > 0, size.height > 0 else { return } maskLayer.path = bodyArrowPath(for: size) maskLayer.frame = bounds // Clip the content view to the same rounded body rect so its // corners match the backdrop instead of showing sharp edges. let cb = content.bounds if cb.width > 0, cb.height > 0 { contentMaskLayer.path = CGPath( roundedRect: cb, cornerWidth: MenuBandPopoverPanel.cornerRadius, cornerHeight: MenuBandPopoverPanel.cornerRadius, transform: nil) contentMaskLayer.frame = cb } } /// Build the continuous body+arrow silhouette path for a given panel /// `size` (chrome-local coords, origin bottom-left). Extracted from /// `rebuildMask` so the mask can be *animated* to a new size (see /// `animateMask(toPanelSize:duration:timing:)`) rather than only /// rebuilt synchronously in `layout()` — which is what made the /// instrument-picker open snap instead of slide. private func bodyArrowPath(for size: CGSize) -> CGPath { let arrowH = MenuBandPopoverPanel.arrowHeight let arrowW = MenuBandPopoverPanel.arrowWidth let radius = MenuBandPopoverPanel.cornerRadius // Body rect: the rounded rectangle below the arrow strip. // The arrow is drawn as a small triangle attached to the body's // top edge, so the mask is one continuous path. let bodyTop = size.height - arrowH let arrowTipX = max(arrowOffsetFromLeft, radius + arrowW / 2 + 2) let path = CGMutablePath() // Bottom-left → bottom-right with rounded corners path.move(to: CGPoint(x: 0, y: radius)) path.addArc(tangent1End: CGPoint(x: 0, y: 0), tangent2End: CGPoint(x: radius, y: 0), radius: radius) path.addLine(to: CGPoint(x: size.width - radius, y: 0)) path.addArc(tangent1End: CGPoint(x: size.width, y: 0), tangent2End: CGPoint(x: size.width, y: radius), radius: radius) // Right side up to body top path.addLine(to: CGPoint(x: size.width, y: bodyTop - radius)) path.addArc(tangent1End: CGPoint(x: size.width, y: bodyTop), tangent2End: CGPoint(x: size.width - radius, y: bodyTop), radius: radius) // Body top edge → arrow base right path.addLine(to: CGPoint(x: arrowTipX + arrowW / 2, y: bodyTop)) // Arrow tip path.addLine(to: CGPoint(x: arrowTipX, y: size.height)) // Arrow base left path.addLine(to: CGPoint(x: arrowTipX - arrowW / 2, y: bodyTop)) // Continue body top edge to top-left corner radius path.addLine(to: CGPoint(x: radius, y: bodyTop)) path.addArc(tangent1End: CGPoint(x: 0, y: bodyTop), tangent2End: CGPoint(x: 0, y: bodyTop - radius), radius: radius) path.closeSubpath() return path } /// Animate the liquid-glass silhouette + content clip to a new panel /// size in lockstep with the window's frame animation, so the body /// grows/shrinks fluidly (instrument-picker slide) instead of the /// mask snapping to the final shape while the frame catches up — the /// desync that read as "the whole popover moves." func animateMask(toPanelSize size: CGSize, duration: TimeInterval, timing: CAMediaTimingFunction) { let radius = MenuBandPopoverPanel.cornerRadius let bodyTarget = bodyArrowPath(for: size) let contentRect = CGRect( x: 0, y: 0, width: size.width, height: max(0, size.height - MenuBandPopoverPanel.arrowHeight)) let contentTarget = CGPath(roundedRect: contentRect, cornerWidth: radius, cornerHeight: radius, transform: nil) func animate(_ layer: CAShapeLayer, to target: CGPath, frame: CGRect) { let anim = CABasicAnimation(keyPath: "path") anim.fromValue = layer.presentation()?.path ?? layer.path anim.toValue = target anim.duration = duration anim.timingFunction = timing layer.path = target layer.frame = frame layer.add(anim, forKey: "maskResize") } animate(maskLayer, to: bodyTarget, frame: CGRect(origin: .zero, size: size)) animate(contentMaskLayer, to: contentTarget, frame: contentRect) } }