diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift deleted file mode 100644 index 4a2a787..0000000 --- a/Sources/phbar/AppDelegate.swift +++ /dev/null @@ -1,180 +0,0 @@ -import AppKit - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - /// 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? - - /// `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) - self.factory = PHBarFactory(config: config, environment: environment, debug: debug) - super.init() - } - - func applicationDidFinishLaunching(_ notification: Notification) { - 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() } - } - observers.append(token) - } - - func refresh() { - for controller in controllers { - controller.refresh() - } - } - - func applicationWillTerminate(_ notification: Notification) { - for controller in controllers { - 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 63ebe79..d2672d7 100644 --- a/Sources/phbar/CLI/cli+start.swift +++ b/Sources/phbar/CLI/cli+start.swift @@ -21,7 +21,7 @@ extension phbar { try MainActor.assumeIsolated { guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") } - let delegate = AppDelegate(config: config, debug: debug) + let delegate = PHBarDelegate(config: config, debug: debug) let app = NSApplication.shared app.setActivationPolicy(.accessory) app.delegate = delegate diff --git a/Sources/phbar/Models/PHBarDelegate.swift b/Sources/phbar/Models/PHBarDelegate.swift new file mode 100644 index 0000000..d127801 --- /dev/null +++ b/Sources/phbar/Models/PHBarDelegate.swift @@ -0,0 +1,69 @@ +import AppKit + +@MainActor +final class PHBarDelegate: NSObject, NSApplicationDelegate { + /// 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. + var factory: PHBarFactory + + private var observers: [IPCObserver] = [] + private var screenObserver: NSObjectProtocol? + + /// `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) + self.factory = PHBarFactory(config: config, environment: environment, debug: debug) + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + 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() } + } + observers.append(token) + } + + func refresh() { + for controller in controllers { + controller.refresh() + } + } + + func applicationWillTerminate(_ notification: Notification) { + for controller in controllers { + controller.stopAutoRefresh() + } + observers.removeAll() + if let screenObserver { + NotificationCenter.default.removeObserver(screenObserver) + self.screenObserver = nil + } + } + + func stderr(_ message: String) { + FileHandle.standardError.write(Data("phbar: \(message)\n".utf8)) + } +} diff --git a/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Lifecycle.swift b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Lifecycle.swift new file mode 100644 index 0000000..ad1fc9b --- /dev/null +++ b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Lifecycle.swift @@ -0,0 +1,43 @@ +import AppKit + +extension PHBarDelegate { + /// 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 + 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). + 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. + func window(for screen: NSScreen) -> BarWindow? { + windows.first { $0.barController.screen === screen } + } + + /// The window currently running on the monitor named `name`, if any. + func window(named name: String) -> BarWindow? { + windows.first { $0.barController.screen.localizedName == name } + } + +} diff --git a/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift new file mode 100644 index 0000000..51934d1 --- /dev/null +++ b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift @@ -0,0 +1,44 @@ +extension PHBarDelegate { + // 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) + } + } +} diff --git a/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+ScreenSync.swift b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+ScreenSync.swift new file mode 100644 index 0000000..e4df16e --- /dev/null +++ b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+ScreenSync.swift @@ -0,0 +1,28 @@ +import AppKit + +extension PHBarDelegate { + /// 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) + } + } +}