make bar and blocks monitor scoped
This commit is contained in:
@@ -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(
|
||||
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)
|
||||
for controller in controllers {
|
||||
let window = BarWindow(controller: controller)
|
||||
window.orderFront(nil)
|
||||
barController.startAutoRefresh()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
[[window]]
|
||||
name = "default"
|
||||
anchor = "top"
|
||||
height = 30
|
||||
width = "100%"
|
||||
shadow = false
|
||||
blur = 0.0
|
||||
|
||||
[[window]]
|
||||
name = "bottom"
|
||||
anchor = "bottom"
|
||||
height = 30
|
||||
width = "100%"
|
||||
shadow = false
|
||||
blur = 0.0
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user