From d7e4880fb5e4c29c19c092f36c328a8c966996bd Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 27 May 2026 17:29:52 -0700 Subject: [PATCH] slab/menubar: live-tick pop render bars while menu is open NSMenu's menuNeedsUpdate fires only once per open and the default Timer mode pauses while tracking, so the progress bars sat frozen once the dropdown was up. Tag each pop-render row with a stable identifier, add a .common-mode 0.5s timer on menuWillOpen that re-reads ~/.ac-pop-renders/ and rewrites titles in place (no structural mutation mid-tracking), and tear it down on menuDidClose. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SlabMenubar/AppDelegate.swift | 36 +++++++++++++++++ .../Sources/SlabMenubar/MenuBuilder.swift | 39 ++++++++++++++++++- .../Sources/SlabMenubar/StateSnapshot.swift | 5 ++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 9c5248ca8..424a33ed9 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -28,6 +28,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// tick so the bg alternates between its base and pulse palettes. private var blinkTimer: Timer? private var blinkPhase: Bool = false + /// Live-updates the pop-render progress bars while the menu is open. + /// Re-reads ~/.ac-pop-renders/ (cheap — small dir of small JSON files) + /// and rewrites each tagged row's title in place. Scheduled in + /// `.common` mode so it actually fires while NSMenu tracks events; + /// invalidated in `menuDidClose`. + private var popRenderTimer: Timer? /// `sessionId → "|"` of the last theme/title we pushed /// to Terminal, so the per-tick refresh only fires osascript when /// something actually changed. @@ -190,6 +196,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) } + /// Once the menu is on screen, tick the pop-render progress bars + /// live. NSMenu's default mode pauses Timers (and `menuNeedsUpdate` + /// only fires once per open), so we add this one to `.common` modes + /// and rewrite titles in place rather than restructuring the menu. + func menuWillOpen(_ menu: NSMenu) { + guard menu === self.menu else { return } + popRenderTimer?.invalidate() + guard !state.popRenders.isEmpty else { return } + let t = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in + self?.tickPopRenders() + } + popRenderTimer = t + RunLoop.main.add(t, forMode: .common) + } + + func menuDidClose(_ menu: NSMenu) { + guard menu === self.menu else { return } + popRenderTimer?.invalidate() + popRenderTimer = nil + } + + private func tickPopRenders() { + // Cheap pop-only refresh: skip the full gather() (ioreg / pmset / + // tailscale) and just re-read the heartbeat dir. Safe on main — + // a few KB of JSON in a tiny directory. + let fresh = StateSnapshot.readPopRenders() + state.popRenders = fresh + MenuBuilder.updatePopRenders(in: menu, state: state) + } + private func updateIcon() { guard let button = statusItem.button else { return } // Don't tint our color image — contentTintColor would otherwise diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 08873814e..f1ef11ac4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -92,9 +92,22 @@ enum MenuBuilder { return it } + /// Identifier prefix used to tag each pop-render NSMenuItem so + /// `updatePopRenders(in:state:)` can find and rewrite it in place + /// while the menu is open. Full identifier = prefix + heartbeat id. + static let popRenderItemIDPrefix = "pop-render:" + + /// Monospaced font used for both initial build and live updates so + /// the bar/cell widths stay aligned tick-to-tick. + private static let renderFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + /// Temporary progress bars — one monospaced row per live /pop render /// heartbeat (audio / illy / video). Present only while a render is /// running; the rows vanish when the heartbeat files are gone. + /// + /// Each row is tagged with `pop-render:` so + /// `updatePopRenders(in:state:)` can rewrite its title in place while + /// the menu is on screen, without restructuring the menu mid-tracking. private static func appendPopRenders(to menu: NSMenu, state: StateSnapshot) { guard !state.popRenders.isEmpty else { return } let n = state.popRenders.count @@ -102,14 +115,38 @@ enum MenuBuilder { for r in state.popRenders { let it = NSMenuItem(title: renderLine(r), action: nil, keyEquivalent: "") it.isEnabled = false + it.identifier = NSUserInterfaceItemIdentifier(popRenderItemIDPrefix + r.id) it.attributedTitle = NSAttributedString( string: renderLine(r), - attributes: [.font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)]) + attributes: [.font: renderFont]) menu.addItem(it) } menu.addItem(.separator()) } + /// Rewrite the title of each pop-render row from the latest snapshot. + /// Called from a fast `.common`-mode timer started in `menuWillOpen`, + /// so the bars tick live while the dropdown is open. Pure title + /// mutation — never adds or removes items mid-tracking (that's the + /// historical sharp edge); rows that disappear from the heartbeat + /// dir just stop updating until the menu next opens. + static func updatePopRenders(in menu: NSMenu, state: StateSnapshot) { + guard !state.popRenders.isEmpty else { return } + var byID: [String: PopRender] = [:] + for r in state.popRenders { byID[r.id] = r } + for item in menu.items { + guard let raw = item.identifier?.rawValue, + raw.hasPrefix(popRenderItemIDPrefix) else { continue } + let id = String(raw.dropFirst(popRenderItemIDPrefix.count)) + guard let r = byID[id] else { continue } + let line = renderLine(r) + if item.attributedTitle?.string == line { continue } + item.attributedTitle = NSAttributedString( + string: line, + attributes: [.font: renderFont]) + } + } + /// `illy ▓▓▓▓▓▓░░░░░░ 58% 142/240 helpabeach-p` private static func renderLine(_ r: PopRender) -> String { let width = 12 diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 00193d618..0f9714c45 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -74,7 +74,10 @@ struct StateSnapshot { /// Cheap — a small dir of tiny JSON files — and gather() already /// runs off the main tick. Sweeps stale files (writer pid gone, or /// heartbeat older than 120 s) so a crashed render leaves no ghost. - private static func readPopRenders() -> [PopRender] { + /// + /// Internal (not private) so AppDelegate can do a fast pop-only + /// re-poll while the menu is open without paying for a full gather(). + static func readPopRenders() -> [PopRender] { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".ac-pop-renders") guard let entries = try? FileManager.default.contentsOfDirectory( -- 2.51.2