make bar and blocks monitor scoped

This commit is contained in:
2026-07-12 12:56:35 +02:00
parent 34921ca377
commit 8315b8a6e3
16 changed files with 572 additions and 69 deletions
+31 -17
View File
@@ -2,32 +2,42 @@ import AppKit
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var barController: BarController!
var window: BarWindow!
var controllers: [BarController] = []
var windows: [BarWindow] = []
private var observers: [IPCObserver] = []
/// Create one bar controller per screen. Each screen resolves its own
/// window and block set from the config, so refresh state and layout stay
/// independent across monitors.
init(
config: PHConfig,
screen: NSScreen,
screens: [NSScreen],
theme: PHTheme,
blocks: [PHBlock],
debug: Bool
) {
) throws {
super.init()
self.barController = BarController(
config: config,
screen: screen,
theme: theme,
blocks: blocks,
debug: debug
)
for screen in screens {
let blocks = try PHBlock.load(config.blocks(for: screen))
controllers.append(
BarController(
config: config,
screen: screen,
theme: theme,
blocks: blocks,
debug: debug
)
)
}
}
func applicationDidFinishLaunching(_ notification: Notification) {
window = BarWindow(controller: barController)
window.orderFront(nil)
barController.startAutoRefresh()
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
@@ -39,11 +49,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func refresh() {
barController.refresh()
for controller in controllers {
controller.refresh()
}
}
func applicationWillTerminate(_ notification: Notification) {
barController.stopAutoRefresh()
for controller in controllers {
controller.stopAutoRefresh()
}
observers.removeAll()
}
}
+10 -27
View File
@@ -14,16 +14,23 @@ extension phbar {
mutating func run() throws {
let config = try PHConfig.load()
let screen = try targetScreen(monitor: 0)
let theme = try PHTheme.load("voltage")
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
let blocks = try PHBlock.load()
// 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") }
let delegate = AppDelegate(config: config, screen: screen, theme: theme, blocks: blocks, debug: debug)
let delegate = try AppDelegate(
config: config,
screens: screens,
theme: theme,
debug: debug
)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
@@ -34,27 +41,3 @@ extension phbar {
}
}
}
// Monitor
extension phbar.start {
private func targetScreen(monitor: Int) throws -> NSScreen {
let screens = NSScreen.screens
guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") }
if monitor >= 0, monitor < screens.count {
return screens[monitor]
}
// Default: screen containing the mouse cursor
let mouseLocation = NSEvent.mouseLocation
guard
let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) })
?? screens.first
else {
throw CleanExit.message("No monitor founded")
}
return screen
}
}
@@ -10,18 +10,26 @@ extension PHBlock {
}
}
nonisolated static var configFile: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml")
nonisolated static var configDirectory: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar")
}
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
static func load() throws -> [PHBlock] {
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
return try load(from: Self.configFile)
} else {
throw phbar.Error("Failed to load blocks file")
nonisolated static var blocksDirectory: URL {
Self.configDirectory.appending(path: "blocks")
}
/// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
///
/// - Parameter configDirectory: Override the lookup root (used by tests);
/// defaults to `~/.config/phbar`.
static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] {
let base = configDirectory ?? Self.configDirectory
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
throw phbar.Error("Failed to load blocks '\(blocks)'")
}
return try load(from: setFile)
}
/// Load theme from a file at URL.
@@ -85,7 +85,10 @@ extension PHBlock {
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process()
process.currentDirectoryURL = Self.configFile.deletingLastPathComponent()
// Commands run with the config root (~/.config/phbar) as their working
// directory, regardless of which block set they belong to, so relative
// paths in user scripts stay stable.
process.currentDirectoryURL = Self.configDirectory
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
@@ -0,0 +1,22 @@
import AppKit
extension PHConfig {
/// The per-monitor override that applies to `screen`, if any.
/// Precedence: monitor name monitor index.
func monitorOverride(for screen: NSScreen) -> PHMonitorOverride? {
let index = NSScreen.screens.firstIndex(of: screen)
return monitorOverride(screenName: screen.localizedName, screenIndex: index)
}
/// Window definition name for `screen`: the override's `window` if set,
/// otherwise the global `window`.
func windowName(for screen: NSScreen) -> String {
monitorOverride(for: screen)?.window ?? window
}
/// Block-set name for `screen`: the override's `blocks` if set, otherwise
/// the global `blocks` (defaulting to `"default"`).
func blocks(for screen: NSScreen) -> String {
monitorOverride(for: screen)?.blocks ?? (blocks ?? "default")
}
}
@@ -1,4 +1,43 @@
struct PHConfig: Decodable {
let window: String
let blocks: String?
let env: [String: String]?
let monitors: [String: PHMonitorOverride]?
private enum CodingKeys: String, CodingKey {
case window, blocks, env
case monitors = "monitor"
}
}
/// A per-monitor entry from the config's `[monitor]` table.
///
/// Either field is optional; an unset field inherits the matching global
/// (`window` or `blocks`) so a monitor can override just one of the two.
struct PHMonitorOverride: Decodable {
let window: String?
let blocks: String?
}
extension PHConfig {
/// The override matching a screen identifier, if any.
/// Pure (no AppKit). Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHMonitorOverride? {
guard let monitors else { return nil }
if let byName = monitors[screenName] { return byName }
if let index = screenIndex, let byIndex = monitors[String(index)] { return byIndex }
return nil
}
/// Window definition name for a screen (pure resolver).
/// Override's `window` if set, else the global `window`.
func windowName(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
}
/// Block-set name for a screen (pure resolver).
/// Override's `blocks` if set, else the global `blocks` (default `"default"`).
func blocksName(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default")
}
}
+19 -2
View File
@@ -1,2 +1,19 @@
window = "top"
height = 30
# Default window and block set for every monitor.
# - window: a [[window]] definition from the theme file
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml
window = "default"
blocks = "default"
# Optional: override the window and/or block set per monitor.
# - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value.
#
# [monitor."1"]
# window = "bottom"
# blocks = "laptop"
#
# [monitor."DELL U2723QE"]
# window = "clock"
# blocks = "external"
@@ -0,0 +1,64 @@
import CoreGraphics
import Foundation
/// Where a window is placed within its screen.
///
/// Single-edge anchors (`top`, `bottom`, `leading`, `trailing`) pin to that
/// edge and center along the opposite axis. Corner anchors pin to two edges.
/// `center` centers the window on both axes.
enum PHThemeAnchor: String, Decodable {
case top, bottom, leading, trailing
case topLeading = "top-leading"
case topTrailing = "top-trailing"
case bottomLeading = "bottom-leading"
case bottomTrailing = "bottom-trailing"
case center
}
extension PHThemeAnchor {
/// Compute the top-left origin (in screen coordinates) of a window of `size`
/// placed against this anchor within `screen`, offset by `margin` along the
/// anchored edges.
///
/// Edges the anchor does not reference are ignored, so margins only affect
/// the relevant sides.
func origin(in screen: CGRect, size: CGSize, margin: PHThemeMargin) -> CGPoint {
let top = margin.top ?? 0
let bottom = margin.bottom ?? 0
let leading = margin.leading ?? 0
let trailing = margin.trailing ?? 0
// Default: centered on both axes.
var point = CGPoint(
x: screen.midX - size.width / 2,
y: screen.midY - size.height / 2
)
switch self {
case .top:
point.y = screen.maxY - CGFloat(top) - size.height
case .bottom:
point.y = screen.minY + CGFloat(bottom)
case .leading:
point.x = screen.minX + CGFloat(leading)
case .trailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
case .topLeading:
point.x = screen.minX + CGFloat(leading)
point.y = screen.maxY - CGFloat(top) - size.height
case .topTrailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
point.y = screen.maxY - CGFloat(top) - size.height
case .bottomLeading:
point.x = screen.minX + CGFloat(leading)
point.y = screen.minY + CGFloat(bottom)
case .bottomTrailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
point.y = screen.minY + CGFloat(bottom)
case .center:
break
}
return point
}
}
@@ -0,0 +1,48 @@
import CoreGraphics
import Foundation
/// A length expressed either in points or as a percentage of the available
/// space (e.g. the screen width).
///
/// Decoded from either a number (`width = 400`) or a percentage string
/// (`width = "100%"`).
enum PHThemeDimension: Decodable, Equatable {
case points(Double)
case percentage(Double)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// TOML encodes integers and floats distinctly, so accept both.
if let double = try? container.decode(Double.self) {
self = .points(double)
return
}
if let int = try? container.decode(Int.self) {
self = .points(Double(int))
return
}
if let string = try? container.decode(String.self) {
let trimmed = string.trimmingCharacters(in: .whitespaces)
if trimmed.hasSuffix("%"), let value = Double(trimmed.dropLast()) {
self = .percentage(value / 100.0)
return
}
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: #"Expected a number or a percentage string like "100%""#
)
}
/// Resolve the dimension against a total length.
func resolve(against length: CGFloat) -> CGFloat {
switch self {
case .points(let value):
return CGFloat(value)
case .percentage(let ratio):
return length * CGFloat(ratio)
}
}
}
@@ -0,0 +1,24 @@
import Foundation
/// Insets (in points) from the screen edges implied by a window's anchor.
///
/// Only the edges referenced by the anchor are consumed; the rest are ignored,
/// so a margin only affects the relevant sides of the placement.
struct PHThemeMargin: Decodable, Equatable {
let top: Double?
let bottom: Double?
let leading: Double?
let trailing: Double?
init(
top: Double? = nil,
bottom: Double? = nil,
leading: Double? = nil,
trailing: Double? = nil
) {
self.top = top
self.bottom = bottom
self.leading = leading
self.trailing = trailing
}
}
@@ -0,0 +1,10 @@
import Foundation
/// An absolute screen-space origin.
///
/// When set on a window, it overrides anchor/margin placement entirely the
/// window appears at exactly these coordinates.
struct PHThemePoint: Decodable, Equatable {
let x: Double
let y: Double
}
@@ -1,12 +1,26 @@
import Foundation
struct PHThemeWindow: Decodable {
let name: String
var hasShadow: Bool? = false
var blur: Double? = 0
/// Window height in points.
var height: Double? = 30
/// Window width: a point value or a percentage of the screen width.
var width: PHThemeDimension? = .percentage(1.0)
/// Semantic placement within the screen.
var anchor: PHThemeAnchor? = .top
/// Insets from the anchored edges.
var margin: PHThemeMargin? = .init()
/// Absolute origin. Overrides `anchor` and `margin` when set.
var origin: PHThemePoint?
private enum CodingKeys: String, CodingKey {
case name
case hasShadow = "shadow"
case blur
case height, width, anchor, margin, origin
}
static let `default`: Self = {
+8 -2
View File
@@ -3,12 +3,18 @@
# Place at ~/.config/phbar/themes/voltage.toml
[[window]]
name = "default"
name = "default"
anchor = "top"
height = 30
width = "100%"
shadow = false
blur = 0.0
[[window]]
name = "bottom"
name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
shadow = false
blur = 0.0
+12 -3
View File
@@ -20,7 +20,7 @@ final class BarController: ObservableObject {
self.config = config
self.screen = screen
self.theme = theme
self.window = Self.window(for: config, from: theme)
self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = blocks
for block in blocks {
@@ -34,8 +34,17 @@ final class BarController: ObservableObject {
// Theming
extension BarController {
static func window(for config: PHConfig, from theme: PHTheme) -> PHThemeWindow {
guard let window = theme.windows?.first(where: { $0.name == config.window }) else {
/// Resolve the window definition for a given screen, applying any
/// per-monitor override from the config. Selection itself lives on
/// `PHConfig` (see `windowName(for:)`).
static func window(for config: PHConfig, screen: NSScreen, from theme: PHTheme) -> PHThemeWindow {
resolve(window: config.windowName(for: screen), from: theme)
}
/// Look up a window definition by name, falling back to "default" then the
/// built-in default.
static func resolve(window name: String, from theme: PHTheme) -> PHThemeWindow {
guard let window = theme.windows?.first(where: { $0.name == name }) else {
guard let defaultWindow = theme.windows?.first(where: { $0.name == "default" }) else {
return PHThemeWindow.default
}
+24 -5
View File
@@ -46,13 +46,32 @@ final class BarWindow: NSPanel {
}
extension BarWindow {
/// Calculate window frame and position.
/// Calculate window frame and position from the theme window definition,
/// resolved against the controller's screen.
static func computeFrame(from controller: BarController) -> NSRect {
let screenFrame = controller.screen.frame
let height: CGFloat = 30
let x = screenFrame.minX
let y = screenFrame.maxY - height
let themeWindow = controller.window
return NSRect(x: x, y: y, width: screenFrame.width, height: height)
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: screenFrame.width),
height: CGFloat(themeWindow.height ?? 30)
)
// Absolute origin wins over anchor/margin.
if let origin = themeWindow.origin {
return NSRect(
x: CGFloat(origin.x),
y: CGFloat(origin.y),
width: size.width,
height: size.height
)
}
let anchor = themeWindow.anchor ?? .top
let margin = themeWindow.margin ?? .init()
return NSRect(
origin: anchor.origin(in: screenFrame, size: size, margin: margin),
size: size
)
}
}