From c0879f2fe88b872f55fd9a536221d0db83e57359 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 15 Jul 2026 11:48:31 -0700 Subject: [PATCH] =?UTF-8?q?menuband:=20on-screen=203=C2=B72=C2=B71=20visua?= =?UTF-8?q?l=20countdown=20for=20the=20record=20count-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CountdownOverlay — a full-screen, click-through, all-spaces screen-saver panel that punches each numeral in with a scale-pop, synced to the existing voice count-in in beginCountIn (3/2/1 at 0.7s, 2.1s total). Cleared on the downbeat and on cancel. Kept separate from FocusFlashOverlay because that overlay fades its whole window for the red charge glow, so the numbers need their own full-brightness layer on top. --- .../Sources/MenuBand/AppDelegate.swift | 7 +- .../Sources/MenuBand/CountdownOverlay.swift | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 slab/menuband/Sources/MenuBand/CountdownOverlay.swift diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 8a5647be85..8c80bc1594 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -4267,12 +4267,16 @@ 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… + let w = DispatchWorkItem { + CountInVoice.shared.play(n) // jeffrey: 3… 2… 1… + CountdownOverlay.shared.show(n) // big on-screen numeral + } 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 self?.startRecordingMode() } countInWork.append(go) @@ -4287,6 +4291,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { countInWork.removeAll() CountInWhoosh.shared.stop() FocusFlashOverlay.shared.endCharge(fadeOut: true) + CountdownOverlay.shared.clear() } /// 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 new file mode 100644 index 0000000000..a9c766eb59 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/CountdownOverlay.swift @@ -0,0 +1,80 @@ +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) + } +} -- 2.51.2