From 3c5222d800d169690f4a4a18c2cb610eb65e04b2 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Sun, 12 Jul 2026 19:51:42 +0200 Subject: [PATCH] refresh the bars on screen changes --- Sources/phbar/AppDelegate.swift | 188 +++++++++++++++++++----- Sources/phbar/CLI/cli+start.swift | 11 +- Sources/phbar/Models/PHBarFactory.swift | 39 +++++ Sources/phbar/Models/PHScreenSync.swift | 31 ++++ Sources/phbar/Views/BarWindow.swift | 7 + Tests/phbarTests/AppDelegateTests.swift | 78 ++++++++++ 6 files changed, 305 insertions(+), 49 deletions(-) create mode 100644 Sources/phbar/Models/PHBarFactory.swift create mode 100644 Sources/phbar/Models/PHScreenSync.swift create mode 100644 Tests/phbarTests/AppDelegateTests.swift diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift index a55e41e..4a2a787 100644 --- a/Sources/phbar/AppDelegate.swift +++ b/Sources/phbar/AppDelegate.swift @@ -2,57 +2,46 @@ import AppKit @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var controllers: [BarController] = [] + /// One window per attached screen — the single source of truth. The matching + /// controllers are reached through `windows[i].barController`. var windows: [BarWindow] = [] + + /// Convenience accessor over `windows`. + var controllers: [BarController] { windows.map(\.barController) } + + /// Resolves a controller for any screen from the current config. Swapped by + /// `reloadAll()` so a live reload re-reads theme/blocks from disk. + private(set) var factory: PHBarFactory + private var observers: [IPCObserver] = [] + private var screenObserver: NSObjectProtocol? - /// Create one bar controller per screen. Each screen resolves its own - /// theme, window and block set from the config, so refresh state and layout stay - /// independent across monitors. - init( - config: PHConfig, - screens: [NSScreen], - debug: Bool - ) throws { - super.init() - - // The merged environment (process + `[env]`) is shared by every screen: - // scripts see it as their process environment, and themes expand `$VAR` - // references in colors/fonts against it. + /// `config` is captured here (not re-read per screen) so all bars share one + /// resolved config + environment for their lifetime. Live reload swaps the + /// whole factory via `reloadAll()`. + init(config: PHConfig, debug: Bool) { let environment = PHEnvironment.process.merging(config.env) - - for screen in screens { - let name = screen.localizedName - let index = NSScreen.screens.firstIndex(of: screen) - let theme = try PHTheme.load(config.theme(screenName: name, screenIndex: index), environment: environment) - let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) - - controllers.append( - BarController( - config: config, - screen: screen, - theme: theme, - blocks: blocks, - environment: environment, - debug: debug - ) - ) - } + self.factory = PHBarFactory(config: config, environment: environment, debug: debug) + super.init() } func applicationDidFinishLaunching(_ notification: Notification) { - for controller in controllers { - let window = BarWindow(controller: controller) - window.orderFront(nil) - windows.append(window) - controller.startAutoRefresh() + syncScreens() + + // React to monitor connect/disconnect and resolution changes. macOS + // delivers a single app-level notification for any of these; `syncScreens` + // diffs so unchanged bars are left untouched. + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.syncScreens() } } // Listen for refresh notifications from `phbar refresh`. let token = IPC.observe(.refresh) { [weak self] _ in - Task { @MainActor in - self?.refresh() - } + Task { @MainActor in self?.refresh() } } observers.append(token) } @@ -68,5 +57,124 @@ final class AppDelegate: NSObject, NSApplicationDelegate { controller.stopAutoRefresh() } observers.removeAll() + if let screenObserver { + NotificationCenter.default.removeObserver(screenObserver) + self.screenObserver = nil + } + } + + // MARK: - Screen sync + + /// Reconcile live bars against `screens`: tear down bars whose screen is + /// gone, recompute frames for screens whose geometry may have changed, and + /// add bars for newly attached screens. + /// + /// A screen whose theme or blocks fail to load is skipped (with a stderr + /// message) rather than aborting the rest. + /// + /// - Parameter screens: Defaults to `NSScreen.screens`; injected for tests. + func syncScreens(screens: [NSScreen] = NSScreen.screens) { + let plan = PHScreenSync.diff( + current: screens, + occupied: controllers.map(\.screen) + ) + + for screen in plan.removed { + removeBar(for: screen) + } + for screen in plan.kept { + window(for: screen)?.recomputeFrame() + } + for screen in plan.added { + addBar(for: screen) + } + } + + // MARK: - Reload (entry points for a future `phbar reload`) + + /// Re-read config from disk and rebuild every bar against it. Screens are + /// kept; only theme/blocks/window are re-resolved. This is the global path + /// for picking up config edits at runtime. + func reloadAll() { + let config: PHConfig + do { + config = try PHConfig.load() + } catch { + stderr("reload failed, could not read config: \(error)") + return + } + factory = PHBarFactory( + config: config, + environment: PHEnvironment.process.merging(config.env), + debug: factory.debug + ) + rebuildAllBars() + } + + /// Rebuild a single monitor's bar against the current factory. Per-monitor + /// path for picking up config edits at runtime. Returns `false` if no bar + /// currently runs on `name` (or its reload failed to load). + @discardableResult + func reload(screenNamed name: String) -> Bool { + guard let screen = window(named: name)?.barController.screen else { return false } + removeBar(for: screen) + return addBar(for: screen) + } + + /// Tear down every bar and rebuild against the current factory, preserving + /// the set of screens. + private func rebuildAllBars() { + let screens = controllers.map(\.screen) + for screen in screens { + removeBar(for: screen) + } + for screen in screens { + addBar(for: screen) + } + } + + // MARK: - Bar lifecycle + + /// Build, show, and start a bar for `screen`. Returns `false` (and prints to + /// stderr) if the screen's theme or blocks fail to load, so one bad screen + /// can't take down the others. + @discardableResult + private func addBar(for screen: NSScreen) -> Bool { + let controller: BarController + do { + controller = try factory.make(for: screen) + } catch { + stderr("skipping screen \(screen.localizedName): \(error)") + return false + } + let window = BarWindow(controller: controller) + window.orderFront(nil) + windows.append(window) + controller.startAutoRefresh() + return true + } + + /// Stop refresh, hide, and drop the bar running on `screen` (if any). + private func removeBar(for screen: NSScreen) { + guard let index = windows.firstIndex(where: { $0.barController.screen === screen }) else { return } + windows[index].barController.stopAutoRefresh() + windows[index].orderOut(nil) + windows.remove(at: index) + } + + /// The window currently running on `screen`, if any. + private func window(for screen: NSScreen) -> BarWindow? { + windows.first { $0.barController.screen === screen } + } + + /// The window currently running on the monitor named `name`, if any. + private func window(named name: String) -> BarWindow? { + windows.first { $0.barController.screen.localizedName == name } + } + + // MARK: - Helpers + + private func stderr(_ message: String) { + FileHandle.standardError.write(Data("phbar: \(message)\n".utf8)) } } diff --git a/Sources/phbar/CLI/cli+start.swift b/Sources/phbar/CLI/cli+start.swift index c1b77ed..63ebe79 100644 --- a/Sources/phbar/CLI/cli+start.swift +++ b/Sources/phbar/CLI/cli+start.swift @@ -19,16 +19,9 @@ extension phbar { dispatchPrecondition(condition: .onQueue(.main)) try MainActor.assumeIsolated { - // Spawn a bar on every screen; per-monitor window and block-set - // selection is driven by the config's `[monitor]` overrides. - let screens = NSScreen.screens - guard !screens.isEmpty else { throw CleanExit.message("No monitor found") } + guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") } - let delegate = try AppDelegate( - config: config, - screens: screens, - debug: debug - ) + let delegate = AppDelegate(config: config, debug: debug) let app = NSApplication.shared app.setActivationPolicy(.accessory) app.delegate = delegate diff --git a/Sources/phbar/Models/PHBarFactory.swift b/Sources/phbar/Models/PHBarFactory.swift new file mode 100644 index 0000000..f2dffa5 --- /dev/null +++ b/Sources/phbar/Models/PHBarFactory.swift @@ -0,0 +1,39 @@ +import AppKit + +/// Builds a `BarController` for a screen by resolving its theme, blocks, and +/// window from the config. +/// +/// Extracted from `AppDelegate` so the exact same resolution drives initial +/// launch, hot-plugged monitors (`syncScreens`), and live reload +/// (`reloadAll` / `reload(screenNamed:)`). Each screen's theme/blocks are +/// resolved independently, so a failure on one screen can be skipped without +/// taking down the others. +@MainActor +struct PHBarFactory { + let config: PHConfig + let environment: PHEnvironment + let debug: Bool + + /// Resolve theme + blocks for `screen` and build its controller. + /// + /// - Parameter screen: The screen to build a bar for. Its localized name + /// and index in `NSScreen.screens` select any per-monitor config override. + /// - Throws: `PHTheme.load` / `PHBlock.load` errors for the resolved names. + func make(for screen: NSScreen) throws -> BarController { + let name = screen.localizedName + let index = NSScreen.screens.firstIndex(of: screen) + let theme = try PHTheme.load( + config.theme(screenName: name, screenIndex: index), + environment: environment + ) + let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) + return BarController( + config: config, + screen: screen, + theme: theme, + blocks: blocks, + environment: environment, + debug: debug + ) + } +} diff --git a/Sources/phbar/Models/PHScreenSync.swift b/Sources/phbar/Models/PHScreenSync.swift new file mode 100644 index 0000000..a118a7c --- /dev/null +++ b/Sources/phbar/Models/PHScreenSync.swift @@ -0,0 +1,31 @@ +import AppKit + +/// Which screens to keep, remove, or add when reconciling live bars against the +/// current `NSScreen.screens`. +/// +/// Pure and identity-based (it never reads `NSScreen` properties), so it can be +/// unit-tested with any `[NSScreen]` without driving real window lifecycle. +enum PHScreenSync { + struct Diff: Equatable { + /// Screens that already have a bar and are still attached. Their window + /// frame is recomputed since geometry may have changed. + let kept: [NSScreen] + /// Screens whose bar must be torn down (monitor disconnected). + let removed: [NSScreen] + /// Screens with no bar yet that need one (monitor connected). + let added: [NSScreen] + } + + /// Compare the screens now attached (`current`) against the screens currently + /// occupied by bars (`occupied`). A disconnected monitor reappears as a new + /// `NSScreen` instance, so it is reported as remove + add, never a keep. + static func diff(current: [NSScreen], occupied: [NSScreen]) -> Diff { + let currentIDs = Set(current.map(ObjectIdentifier.init)) + let occupiedIDs = Set(occupied.map(ObjectIdentifier.init)) + return Diff( + kept: occupied.filter { currentIDs.contains(ObjectIdentifier($0)) }, + removed: occupied.filter { !currentIDs.contains(ObjectIdentifier($0)) }, + added: current.filter { !occupiedIDs.contains(ObjectIdentifier($0)) } + ) + } +} diff --git a/Sources/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift index 8eb9234..c42a94f 100644 --- a/Sources/phbar/Views/BarWindow.swift +++ b/Sources/phbar/Views/BarWindow.swift @@ -46,6 +46,13 @@ final class BarWindow: NSPanel { } extension BarWindow { + /// Recompute and apply the frame from the controller's screen and theme. + /// Used when a screen's geometry changes (resolution, layout) so the bar + /// stays correctly anchored without rebuilding the whole window. + func recomputeFrame() { + setFrame(Self.computeFrame(from: barController), display: true) + } + /// Calculate window frame and position from the theme window definition, /// resolved against the controller's screen. static func computeFrame(from controller: BarController) -> NSRect { diff --git a/Tests/phbarTests/AppDelegateTests.swift b/Tests/phbarTests/AppDelegateTests.swift new file mode 100644 index 0000000..4913d8c --- /dev/null +++ b/Tests/phbarTests/AppDelegateTests.swift @@ -0,0 +1,78 @@ +import AppKit +import Foundation +import Testing + +@testable import phbar + +@MainActor +struct AppDelegateTests { + + // MARK: - ScreenSync.diff + + @Test func diffReportsAddedWhenNothingOccupied() throws { + let screen = try #require(NSScreen.main) + let plan = PHScreenSync.diff(current: [screen], occupied: []) + #expect(plan.added == [screen]) + #expect(plan.removed.isEmpty) + #expect(plan.kept.isEmpty) + } + + @Test func diffReportsRemovedWhenScreenGone() throws { + let screen = try #require(NSScreen.main) + let plan = PHScreenSync.diff(current: [], occupied: [screen]) + #expect(plan.removed == [screen]) + #expect(plan.added.isEmpty) + #expect(plan.kept.isEmpty) + } + + @Test func diffKeepsScreenStillAttached() throws { + let screen = try #require(NSScreen.main) + let plan = PHScreenSync.diff(current: [screen], occupied: [screen]) + #expect(plan.kept == [screen]) + #expect(plan.added.isEmpty) + #expect(plan.removed.isEmpty) + } + + /// A disconnected monitor reappears as a *new* `NSScreen` instance, so the + /// same physical display must be reported as remove + add, never a keep. + @Test func diffTreatsDistinctInstancesAsRemoveThenAdd() throws { + let main = try #require(NSScreen.main) + guard let other = NSScreen.screens.first(where: { $0 !== main }) else { + // Single-monitor environment: nothing to contrast against. Verify + // identity semantics hold for the same object re-presented instead. + let kept = PHScreenSync.diff(current: [main], occupied: [main]) + #expect(kept.kept == [main]) + return + } + + let plan = PHScreenSync.diff(current: [other], occupied: [main]) + #expect(plan.removed == [main]) + #expect(plan.added == [other]) + #expect(plan.kept.isEmpty) + } + + @Test func diffIgnoresOrdering() throws { + let main = try #require(NSScreen.main) + guard let other = NSScreen.screens.first(where: { $0 !== main }) else { + // Single-monitor environment: ordering is trivially stable. + return + } + let plan = PHScreenSync.diff(current: [other, main], occupied: [main, other]) + #expect(Set(plan.kept.map(ObjectIdentifier.init)) == Set([main, other].map(ObjectIdentifier.init))) + #expect(plan.added.isEmpty) + #expect(plan.removed.isEmpty) + } + + // MARK: - BarControllerFactory + + @Test func factoryBuildsControllerForScreen() throws { + let screen = try #require(NSScreen.main) + let config = try PHConfig.load() + let factory = PHBarFactory(config: config, environment: .process, debug: false) + + let controller = try factory.make(for: screen) + + #expect(controller.screen === screen) + #expect(controller.blocks.isEmpty == false) + } +}