diff --git a/Sources/phbar/AppDelegate.swift b/Sources/phbar/AppDelegate.swift index 366d933..b92c14d 100644 --- a/Sources/phbar/AppDelegate.swift +++ b/Sources/phbar/AppDelegate.swift @@ -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() } } diff --git a/Sources/phbar/CLI/cli+start.swift b/Sources/phbar/CLI/cli+start.swift index 7fe14c6..7b08c70 100644 --- a/Sources/phbar/CLI/cli+start.swift +++ b/Sources/phbar/CLI/cli+start.swift @@ -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 - } -} diff --git a/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift b/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift index b54e128..a3c7e17 100644 --- a/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift +++ b/Sources/phbar/Models/Block/Extensions/PHBlock+Loading.swift @@ -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 `/blocks/.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. diff --git a/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift b/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift index db1b746..4e249dc 100644 --- a/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift +++ b/Sources/phbar/Models/Block/Extensions/PHBlock+Refresh.swift @@ -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] diff --git a/Sources/phbar/Models/Config/Extensions/PHConfig+Screen.swift b/Sources/phbar/Models/Config/Extensions/PHConfig+Screen.swift new file mode 100644 index 0000000..0b516ed --- /dev/null +++ b/Sources/phbar/Models/Config/Extensions/PHConfig+Screen.swift @@ -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") + } +} diff --git a/Sources/phbar/Models/Config/PHConfig.swift b/Sources/phbar/Models/Config/PHConfig.swift index 4a6d7ab..7a79f6d 100644 --- a/Sources/phbar/Models/Config/PHConfig.swift +++ b/Sources/phbar/Models/Config/PHConfig.swift @@ -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") + } } diff --git a/Sources/phbar/Models/Config/config.toml b/Sources/phbar/Models/Config/config.toml index 1cdc149..2e99ede 100644 --- a/Sources/phbar/Models/Config/config.toml +++ b/Sources/phbar/Models/Config/config.toml @@ -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/.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" diff --git a/Sources/phbar/Models/Theme/PHThemeAnchor.swift b/Sources/phbar/Models/Theme/PHThemeAnchor.swift new file mode 100644 index 0000000..5b51c2e --- /dev/null +++ b/Sources/phbar/Models/Theme/PHThemeAnchor.swift @@ -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 + } +} diff --git a/Sources/phbar/Models/Theme/PHThemeDimension.swift b/Sources/phbar/Models/Theme/PHThemeDimension.swift new file mode 100644 index 0000000..67babe1 --- /dev/null +++ b/Sources/phbar/Models/Theme/PHThemeDimension.swift @@ -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) + } + } +} diff --git a/Sources/phbar/Models/Theme/PHThemeMargin.swift b/Sources/phbar/Models/Theme/PHThemeMargin.swift new file mode 100644 index 0000000..58471b5 --- /dev/null +++ b/Sources/phbar/Models/Theme/PHThemeMargin.swift @@ -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 + } +} diff --git a/Sources/phbar/Models/Theme/PHThemePoint.swift b/Sources/phbar/Models/Theme/PHThemePoint.swift new file mode 100644 index 0000000..1228896 --- /dev/null +++ b/Sources/phbar/Models/Theme/PHThemePoint.swift @@ -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 +} diff --git a/Sources/phbar/Models/Theme/PHThemeWindow.swift b/Sources/phbar/Models/Theme/PHThemeWindow.swift index 6420e38..e9c80c2 100644 --- a/Sources/phbar/Models/Theme/PHThemeWindow.swift +++ b/Sources/phbar/Models/Theme/PHThemeWindow.swift @@ -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 = { diff --git a/Sources/phbar/Models/Theme/theme.toml b/Sources/phbar/Models/Theme/theme.toml index 4dd975b..4def825 100644 --- a/Sources/phbar/Models/Theme/theme.toml +++ b/Sources/phbar/Models/Theme/theme.toml @@ -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 diff --git a/Sources/phbar/Views/BarController.swift b/Sources/phbar/Views/BarController.swift index 25b6ff5..56940d6 100644 --- a/Sources/phbar/Views/BarController.swift +++ b/Sources/phbar/Views/BarController.swift @@ -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 } diff --git a/Sources/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift index b43a5c1..30b0269 100644 --- a/Sources/phbar/Views/BarWindow.swift +++ b/Sources/phbar/Views/BarWindow.swift @@ -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 + ) } } diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift index feb456a..3410898 100644 --- a/Tests/phbarTests/phbarTests.swift +++ b/Tests/phbarTests/phbarTests.swift @@ -110,7 +110,7 @@ private struct TestPayload: Codable, Equatable { #expect(blocks[0].command == "echo hello") #expect(blocks[0].name == "greeting") #expect(blocks[0].refresh == 5.0) - #expect(blocks[0].label == "") + #expect(blocks[0].label == nil) #expect(blocks[1].name == nil) #expect(blocks[1].refresh == nil) } @@ -134,6 +134,26 @@ private struct TestPayload: Codable, Equatable { #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 @MainActor @@ -143,7 +163,7 @@ private struct TestPayload: Codable, Equatable { command = "printf panini" """)[0] - #expect(block.label == "") + #expect(block.label == nil) await block.update() @@ -422,7 +442,7 @@ private final class FakeEventSource: PHEventSource { let config = try PHConfig.load() 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() @@ -433,3 +453,206 @@ private final class FakeEventSource: PHEventSource { #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) +} + +