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
+226 -3
View File
@@ -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)
}