Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
33c4cbe26e
|
|||
|
3c5222d800
|
@@ -1,72 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var controllers: [BarController] = []
|
||||
var windows: [BarWindow] = []
|
||||
private var observers: [IPCObserver] = []
|
||||
|
||||
/// 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.
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
for controller in controllers {
|
||||
let window = BarWindow(controller: controller)
|
||||
window.orderFront(nil)
|
||||
windows.append(window)
|
||||
controller.startAutoRefresh()
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -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 = PHBarDelegate(config: config, debug: debug)
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.delegate = delegate
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user