diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 8c80bc1594..70266d2b74 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -4267,16 +4267,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { CountInWhoosh.shared.play(duration: total) FocusFlashOverlay.shared.beginCharge(duration: total) for (i, n) in numbers.enumerated() { - let w = DispatchWorkItem { - CountInVoice.shared.play(n) // jeffrey: 3… 2… 1… - CountdownOverlay.shared.show(n) // big on-screen numeral + let w = DispatchWorkItem { [weak self] in + CountInVoice.shared.play(n) // jeffrey: 3… 2… 1… + KeyboardIconRenderer.countInDigit = n // numeral in the menubar icon + self?.updateIcon() } countInWork.append(w) DispatchQueue.main.asyncAfter(deadline: .now() + interval * Double(i), execute: w) } let go = DispatchWorkItem { [weak self] in self?.countInWork.removeAll() - CountdownOverlay.shared.clear() // downbeat — drop the numerals + KeyboardIconRenderer.countInDigit = nil // downbeat — back to the keyboard + self?.updateIcon() self?.startRecordingMode() } countInWork.append(go) @@ -4291,7 +4293,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { countInWork.removeAll() CountInWhoosh.shared.stop() FocusFlashOverlay.shared.endCharge(fadeOut: true) - CountdownOverlay.shared.clear() + KeyboardIconRenderer.countInDigit = nil + updateIcon() } /// Roll tape (audio-only, no mic). Arms the keyboard so keys play + record, diff --git a/slab/menuband/Sources/MenuBand/CountdownOverlay.swift b/slab/menuband/Sources/MenuBand/CountdownOverlay.swift deleted file mode 100644 index a9c766eb59..0000000000 --- a/slab/menuband/Sources/MenuBand/CountdownOverlay.swift +++ /dev/null @@ -1,80 +0,0 @@ -import AppKit - -/// Big on-screen **3 · 2 · 1** numerals for the record count-in. A full-screen, -/// click-through, all-spaces panel that punches each number in with a quick -/// scale-pop. Kept separate from `FocusFlashOverlay` on purpose: that overlay -/// fades its whole window for the red "charge" glow (max ~0.30 alpha), so the -/// numbers live here at full brightness, layered on top. -/// -/// Non-activating + `.screenSaver` level + click-through, so it never steals -/// focus or eats the keystroke driving the count-in — matches FocusFlashOverlay. -final class CountdownOverlay: NSPanel { - static let shared = CountdownOverlay() - private let label = CATextLayer() - - private init() { - super.init(contentRect: .zero, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false) - isOpaque = false - backgroundColor = .clear - hasShadow = false - level = .screenSaver - ignoresMouseEvents = true - hidesOnDeactivate = false - collectionBehavior = [.canJoinAllSpaces, .stationary, - .ignoresCycle, .fullScreenAuxiliary] - let v = NSView() - v.wantsLayer = true - label.alignmentMode = .center - label.foregroundColor = NSColor.white.cgColor - label.shadowColor = NSColor.black.cgColor - label.shadowOpacity = 0.5 - label.shadowRadius = 14 - label.shadowOffset = CGSize(width: 0, height: -4) - label.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 - label.opacity = 0 - v.layer?.addSublayer(label) - contentView = v - } - - /// Punch a number on screen (scale-pop + quick fade-in). Stays lit until the - /// next `show(_:)` or `clear()`. Main-thread only (called from the count-in - /// work items, which run on the main queue). - func show(_ n: String) { - guard let screen = NSScreen.main else { return } - setFrame(screen.frame, display: true) - orderFrontRegardless() - let side = min(screen.frame.width, screen.frame.height) - let fontSize = side * 0.28 - let bandH = fontSize * 1.25 - label.font = NSFont.systemFont(ofSize: fontSize, weight: .heavy) - label.fontSize = fontSize - label.string = n - // Full-width band so the glyph centers horizontally; the layer scales - // around its own center (anchorPoint 0.5,0.5) = screen center. - label.frame = CGRect(x: 0, y: (screen.frame.height - bandH) / 2, - width: screen.frame.width, height: bandH) - label.removeAllAnimations() - label.opacity = 1 - let pop = CABasicAnimation(keyPath: "transform.scale") - pop.fromValue = 1.35 - pop.toValue = 1.0 - pop.duration = 0.24 - pop.timingFunction = CAMediaTimingFunction(name: .easeOut) - let fade = CABasicAnimation(keyPath: "opacity") - fade.fromValue = 0.0 - fade.toValue = 1.0 - fade.duration = 0.12 - label.add(pop, forKey: "pop") - label.add(fade, forKey: "in") - } - - /// Clear the countdown (downbeat reached, or count-in aborted). - func clear() { - label.removeAllAnimations() - label.opacity = 0 - orderOut(nil) - } -} diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index 576331b089..c2bbc87da6 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -271,6 +271,11 @@ enum KeyboardIconRenderer { /// `sampleRecordingActive` proxy. static var recordingActive: Bool = false + /// When set ("3"/"2"/"1"), the menubar icon shows the record count-in as a + /// Menu-Band-purple pill with the numeral, in place of the keyboard — the + /// countdown lives IN the menubar, not as a full-screen overlay. + static var countInDigit: String? = nil + // Render area shrinks with the layout. Compact has no piano keys at // all — `lastMidi < firstMidi` makes whiteList() empty. static let firstMidi: Int = 60 // C4 (middle C) @@ -781,6 +786,35 @@ enum KeyboardIconRenderer { /// Draw the REC dot (idle) or dot + elapsed timer (recording) in the /// reserved left slot. Drawn in base coords inside the render closure. + /// Draw the record count-in numeral as a Menu-Band-purple pill centered in + /// the icon — white numeral, the app's monospaced-heavy numeric style, a + /// 1px title-shadow. Reads on light or dark menubars; matches the app icon. + private static func drawCountIn(_ digit: String, size: NSSize) { + let fontSize = size.height * 0.70 + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.35) + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 0 + let para = NSMutableParagraphStyle(); para.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .heavy), + .foregroundColor: NSColor.white, + .paragraphStyle: para, + .shadow: shadow, + ] + let ds = (digit as NSString).size(withAttributes: attrs) + let pillH = size.height - 2 + let pillW = min(size.width - 2, ds.width + fontSize * 0.9) + let pill = NSRect(x: (size.width - pillW) / 2, y: (size.height - pillH) / 2, + width: pillW, height: pillH) + NSColor(srgbRed: 0.55, green: 0.16, blue: 0.80, alpha: 1).setFill() // Menu Band purple + NSBezierPath(roundedRect: pill, xRadius: pillH * 0.34, yRadius: pillH * 0.34).fill() + (digit as NSString).draw( + in: NSRect(x: pill.minX, y: pill.midY - ds.height / 2, + width: pill.width, height: ds.height), + withAttributes: attrs) + } + private static func drawRecIndicator(canvasHeight: CGFloat) { let x = recDotLeadPad // Idle: a steady red record dot. @@ -861,6 +895,12 @@ enum KeyboardIconRenderer { scale.concat() } + // Record count-in: a purple pill + numeral takes over the icon. + if includeSettings, let digit = countInDigit { + drawCountIn(digit, size: size) + return true + } + // 🔴 REC dot — fixed slot at the far left, before the piano. // Solid red when idle; a blinking dot + elapsed timer while // recording a tape. Only in the menubar layout (not the palette),