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(