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 @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
var barController: BarController! var controllers: [BarController] = []
var window: BarWindow! var windows: [BarWindow] = []
private var observers: [IPCObserver] = [] 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( init(
config: PHConfig, config: PHConfig,
screen: NSScreen, screens: [NSScreen],
theme: PHTheme, theme: PHTheme,
blocks: [PHBlock],
debug: Bool debug: Bool
) { ) throws {
super.init() super.init()
self.barController = BarController( for screen in screens {
config: config, let blocks = try PHBlock.load(config.blocks(for: screen))
screen: screen, controllers.append(
theme: theme, BarController(
blocks: blocks, config: config,
debug: debug screen: screen,
) theme: theme,
blocks: blocks,
debug: debug
)
)
}
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
window = BarWindow(controller: barController) for controller in controllers {
window.orderFront(nil) let window = BarWindow(controller: controller)
barController.startAutoRefresh() window.orderFront(nil)
windows.append(window)
controller.startAutoRefresh()
}
// Listen for refresh notifications from `phbar refresh`. // Listen for refresh notifications from `phbar refresh`.
let token = IPC.observe(.refresh) { [weak self] _ in let token = IPC.observe(.refresh) { [weak self] _ in
@@ -39,11 +49,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
func refresh() { func refresh() {
barController.refresh() for controller in controllers {
controller.refresh()
}
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
barController.stopAutoRefresh() for controller in controllers {
controller.stopAutoRefresh()
}
observers.removeAll() observers.removeAll()
} }
} }
+10 -27
View File
@@ -14,16 +14,23 @@ extension phbar {
mutating func run() throws { mutating func run() throws {
let config = try PHConfig.load() let config = try PHConfig.load()
let screen = try targetScreen(monitor: 0)
let theme = try PHTheme.load("voltage") let theme = try PHTheme.load("voltage")
// NOTE: Make sure NSApp.run() runs in the main thread // NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main)) dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated { 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 let app = NSApplication.shared
app.setActivationPolicy(.accessory) app.setActivationPolicy(.accessory)
app.delegate = delegate 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 { nonisolated static var configDirectory: URL {
FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar/blocks.toml") FileManager.default.homeDirectoryForCurrentUser.appending(path: ".config/phbar")
} }
/// Load blocks from the default path (~/.config/phbar/blocks.toml). nonisolated static var blocksDirectory: URL {
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal). Self.configDirectory.appending(path: "blocks")
static func load() throws -> [PHBlock] { }
if FileManager.default.fileExists(atPath: Self.configFile.relativePath) {
return try load(from: Self.configFile) /// Load a named block set from `<configDirectory>/blocks/<name>.toml`.
} else { ///
throw phbar.Error("Failed to load blocks file") /// - 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. /// Load theme from a file at URL.
@@ -85,7 +85,10 @@ extension PHBlock {
let lines: [Substring]? = await Task.detached(priority: .userInitiated) { let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
let process = Process() 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.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command] 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 { struct PHConfig: Decodable {
let window: String let window: String
let blocks: String?
let env: [String: 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" # Default window and block set for every monitor.
height = 30 # - 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 { struct PHThemeWindow: Decodable {
let name: String let name: String
var hasShadow: Bool? = false var hasShadow: Bool? = false
var blur: Double? = 0 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 { private enum CodingKeys: String, CodingKey {
case name case name
case hasShadow = "shadow" case hasShadow = "shadow"
case blur case blur
case height, width, anchor, margin, origin
} }
static let `default`: Self = { static let `default`: Self = {
+8 -2
View File
@@ -3,12 +3,18 @@
# Place at ~/.config/phbar/themes/voltage.toml # Place at ~/.config/phbar/themes/voltage.toml
[[window]] [[window]]
name = "default" name = "default"
anchor = "top"
height = 30
width = "100%"
shadow = false shadow = false
blur = 0.0 blur = 0.0
[[window]] [[window]]
name = "bottom" name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
shadow = false shadow = false
blur = 0.0 blur = 0.0
+12 -3
View File
@@ -20,7 +20,7 @@ final class BarController: ObservableObject {
self.config = config self.config = config
self.screen = screen self.screen = screen
self.theme = theme self.theme = theme
self.window = Self.window(for: config, from: theme) self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = blocks self.blocks = blocks
for block in blocks { for block in blocks {
@@ -34,8 +34,17 @@ final class BarController: ObservableObject {
// Theming // Theming
extension BarController { extension BarController {
static func window(for config: PHConfig, from theme: PHTheme) -> PHThemeWindow { /// Resolve the window definition for a given screen, applying any
guard let window = theme.windows?.first(where: { $0.name == config.window }) else { /// 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 { guard let defaultWindow = theme.windows?.first(where: { $0.name == "default" }) else {
return PHThemeWindow.default return PHThemeWindow.default
} }
+24 -5
View File
@@ -46,13 +46,32 @@ final class BarWindow: NSPanel {
} }
extension BarWindow { 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 { static func computeFrame(from controller: BarController) -> NSRect {
let screenFrame = controller.screen.frame let screenFrame = controller.screen.frame
let height: CGFloat = 30 let themeWindow = controller.window
let x = screenFrame.minX
let y = screenFrame.maxY - height
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
)
} }
} }
+226 -3
View File
@@ -110,7 +110,7 @@ private struct TestPayload: Codable, Equatable {
#expect(blocks[0].command == "echo hello") #expect(blocks[0].command == "echo hello")
#expect(blocks[0].name == "greeting") #expect(blocks[0].name == "greeting")
#expect(blocks[0].refresh == 5.0) #expect(blocks[0].refresh == 5.0)
#expect(blocks[0].label == "") #expect(blocks[0].label == nil)
#expect(blocks[1].name == nil) #expect(blocks[1].name == nil)
#expect(blocks[1].refresh == nil) #expect(blocks[1].refresh == nil)
} }
@@ -134,6 +134,26 @@ private struct TestPayload: Codable, Equatable {
#expect(output == "panini") #expect(output == "panini")
} }
@MainActor
@Test func computeRunsInConfigDirectory() async throws {
// Block commands execute with the config root (~/.config/phbar) as their
// working directory, so a block from any set resolves relative paths the
// same way. Guards against the config dir being absent in sandboxed CIs.
guard FileManager.default.fileExists(atPath: PHBlock.configDirectory.path) else { return }
let blocks = try PHBlock.load(from: """
[[block]]
command = "pwd"
""")
let output = await blocks[0].compute()
// Resolve symlinks on both sides: `~/.config/phbar` is commonly a
// symlink into a dotfiles repo, and `pwd` reports the physical path.
let expected = PHBlock.configDirectory.resolvingSymlinksInPath().path
#expect(output == expected)
}
// MARK: - PHBlock Refresh // MARK: - PHBlock Refresh
@MainActor @MainActor
@@ -143,7 +163,7 @@ private struct TestPayload: Codable, Equatable {
command = "printf panini" command = "printf panini"
""")[0] """)[0]
#expect(block.label == "") #expect(block.label == nil)
await block.update() await block.update()
@@ -422,7 +442,7 @@ private final class FakeEventSource: PHEventSource {
let config = try PHConfig.load() let config = try PHConfig.load()
let theme = try PHTheme.load("voltage") let theme = try PHTheme.load("voltage")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks) let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
controller.refresh() controller.refresh()
@@ -433,3 +453,206 @@ private final class FakeEventSource: PHEventSource {
#expect(blocks[1].label == "b") #expect(blocks[1].label == "b")
} }
// MARK: - PHThemeDimension
private struct DimensionWrapper: Decodable {
let v: PHThemeDimension
}
@Test func dimensionDecodesPoints() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400}"#.utf8)).v == .points(400))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":400.5}"#.utf8)).v == .points(400.5))
}
@Test func dimensionDecodesPercentage() throws {
let decoder = JSONDecoder()
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"100%"}"#.utf8)).v == .percentage(1.0))
#expect(try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"50%"}"#.utf8)).v == .percentage(0.5))
}
@Test func dimensionRejectsGarbage() {
let decoder = JSONDecoder()
#expect(throws: (any Error).self) {
try decoder.decode(DimensionWrapper.self, from: Data(#"{"v":"wat"}"#.utf8))
}
}
@Test func dimensionResolvesAgainstLength() {
#expect(PHThemeDimension.points(250).resolve(against: 1000) == 250)
#expect(PHThemeDimension.percentage(0.25).resolve(against: 1000) == 250)
}
// MARK: - PHThemeAnchor geometry
@Test func anchorPlacesAtTopEdge() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeAnchor.top.origin(in: screen, size: size, margin: .init())
#expect(origin == CGPoint(x: 0, y: 770))
}
@Test func anchorRespectsTopMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeAnchor.top.origin(in: screen, size: size, margin: .init(top: 10))
#expect(origin == CGPoint(x: 0, y: 760))
}
@Test func anchorPlacesBottomLeadingCorner() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeAnchor.bottomLeading.origin(
in: screen, size: size, margin: .init(bottom: 8, leading: 12)
)
#expect(origin == CGPoint(x: 12, y: 8))
}
@Test func anchorTrailingCentersVertically() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeAnchor.trailing.origin(
in: screen, size: size, margin: .init(trailing: 20)
)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersOnBothAxesIgnoringMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeAnchor.center.origin(in: screen, size: size, margin: .init(top: 999))
#expect(origin == CGPoint(x: 400, y: 380))
}
// MARK: - Monitor window/blocks resolver
@Test func windowNameFallsBackToGlobal() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
#expect(config.windowName(screenName: "Built-in", screenIndex: 0) == "default")
}
@Test func windowNameMatchesByIndex() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["1": .init(window: "bottom", blocks: nil)])
#expect(config.windowName(screenName: "Whatever", screenIndex: 1) == "bottom")
}
@Test func windowNameMatchesByName() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)])
#expect(config.windowName(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
}
@Test func monitorOverridePrefersNameOverIndex() {
let config = PHConfig(
window: "default", blocks: nil, env: nil,
monitors: ["0": .init(window: "byIndex", blocks: nil), "DELL": .init(window: "byName", blocks: nil)]
)
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "byName")
}
@Test func windowInheritsGlobalWhenOverrideOmitsIt() {
// Override sets blocks only window falls back to the global.
let config = PHConfig(window: "top", blocks: nil, env: nil, monitors: ["1": .init(window: nil, blocks: "laptop")])
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
}
@Test func blocksNameDefaultsToDefault() {
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "default")
}
@Test func blocksNameUsesGlobalWhenNoOverride() {
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: nil)
#expect(config.blocksName(screenName: "S", screenIndex: 0) == "main")
}
@Test func blocksOverrideBeatsGlobal() {
let config = PHConfig(window: "default", blocks: "main", env: nil, monitors: ["1": .init(window: nil, blocks: "alt")])
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "alt")
}
@Test func windowAndBlocksResolveIndependently() {
let config = PHConfig(
window: "top", blocks: "main", env: nil,
monitors: [
"DELL": .init(window: "clock", blocks: nil), // inherits blocks "main"
"1": .init(window: nil, blocks: "laptop"), // inherits window "top"
]
)
#expect(config.windowName(screenName: "DELL", screenIndex: 0) == "clock")
#expect(config.blocksName(screenName: "DELL", screenIndex: 0) == "main")
#expect(config.windowName(screenName: "S", screenIndex: 1) == "top")
#expect(config.blocksName(screenName: "S", screenIndex: 1) == "laptop")
}
// MARK: - PHBlock set loading
private func makeBlocksConfigDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_blocks_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@MainActor
@Test func loadSetReadsNamedFile() throws {
let dir = try makeBlocksConfigDir()
defer { try? FileManager.default.removeItem(at: dir) }
let blocksDir = dir.appending(path: "blocks")
try FileManager.default.createDirectory(at: blocksDir, withIntermediateDirectories: true)
try Data("""
[[block]]
command = "echo hi"
name = "greeting"
""".utf8).write(to: blocksDir.appending(path: "laptop.toml"))
let blocks = try PHBlock.load("laptop", in: dir)
#expect(blocks.count == 1)
#expect(blocks[0].name == "greeting")
}
@MainActor
@Test func loadSetThrowsForMissingSet() {
let dir = FileManager.default.temporaryDirectory.appending(path: "phbar_missing_\(UUID().uuidString)")
#expect(throws: (any Error).self) {
try PHBlock.load("nope", in: dir)
}
}
// MARK: - BarWindow.computeFrame
@MainActor
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
let screen = try #require(NSScreen.main)
let theme = try PHTheme.load("voltage")
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == screen.frame.width)
#expect(frame.minX == screen.frame.minX)
#expect(frame.minY == screen.frame.maxY - 30)
}
@MainActor
@Test func computeFrameUsesAbsoluteOriginOverAnchor() throws {
let screen = try #require(NSScreen.main)
let theme = PHTheme(
windows: [PHThemeWindow(name: "abs", anchor: .bottom, origin: PHThemePoint(x: 100, y: 200))],
texts: nil,
styles: nil
)
let config = PHConfig(window: "abs", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.origin == CGPoint(x: 100, y: 200))
#expect(frame.height == 30)
}